Yesterday we created our first form with symfony. People are now able to post a new job on Jobeet but we ran out of time before we could add some tests.
That's what we will do today. Along the way, we will also learn more about the form framework.
Submitting a Form
Let's open the jobActionsTest
file to add functional tests for the job
creation and validation process.
At the end of the file, add the following code to get the job creation page:
// 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() ;
We have already used the click()
method to simulate clicks on links. The
same click()
method can be used to submit a form. For a form, you can pass
the values to submit for each field as a second argument of the method. Like
a real browser, the browser object will merge the default values of the form
with the submitted values.
But to pass the field values, we need to know their names. If you open the
source code or use the Firefox Web Developer Toolbar "Forms > Display Form
Details" feature, you will see that the name for the company
field is
jobeet_job[company]
.
note
When PHP encounters an input field with a name like jobeet_job[company]
, it
automatically converts it to an array of name jobeet_job
.
To make things look a bit more clean, let's change the format to job[%s]
by adding the
following code at the end of the configure()
method of JobeetJobForm
:
// lib/form/doctrine/JobeetJobForm.class.php $this->widgetSchema->setNameFormat('job[%s]');
After this change, the company
name should be job[company]
in your
browser. It is now time to actually click on the "Preview your job" button
and pass valid values to the 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, )))-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'create')-> end() ;
The browser also simulates file uploads if you pass the absolute path to the file to upload.
After submitting the form, we checked that the executed action is create
.
The Form Tester
The form we have submitted should be valid. You can test this by using the form tester:
with('form')->begin()-> hasErrors(false)-> end()->
The form tester has several methods to test the current form status, like the errors.
If you make a mistake in the test, and the test does not pass, you can use the
with('response')->~debug|Debug~()
statement we have seen during day 9. But you will
have to dig into the generated HTML to check for error messages. That's not
really convenient. The form tester also provides a debug()
method that
outputs the form status and all error messages associated with it:
with('form')->debug()
Redirection Test
As the form is valid, the job should have been created and the user
redirected to the show
page:
isRedirected()-> followRedirect()-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'show')-> end()->
The isRedirected()
tests if the page has been redirected and the
followRedirect()
method follows the redirect.
note
The browser class does not follow redirects automatically as you might want to introspect objects before the redirection.
The Doctrine Tester
Eventually, we want to test that the job has been created in the database and
check that the is_activated
column is set to false
as the user has not
published it yet.
This can be done quite easily by using yet another tester, the Doctrine tester. As the Doctrine tester is not registered by default, let's add it now:
$browser->setTester('doctrine', 'sfTesterDoctrine');
The Doctrine tester provides the check()
method to check that one or more
objects in the database matches the criteria passed as an argument.
with('doctrine')->begin()-> check('JobeetJob', array( 'location' => 'Atlanta, USA', 'is_activated' => false, 'is_public' => false, ))-> end()
The criteria can be an array of values like above, or a Doctrine_Query
instance for more complex queries. You can test the existence of objects
matching the criteria with a Boolean as the third argument (the default is
true
), or the number of matching objects by passing an integer.
Testing for Errors
The job form creation works as expected when we submit valid values. Let's add a test to check the behavior when we submit non-valid data:
$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() ;
The hasErrors()
method can test the number of errors if passed an integer.
The isError()
method tests the error code for a given field.
tip
In the tests we have written for the non-valid data submission, we have not re-tested the entire form all over again. We have only added tests for specific things.
You can also test the generated HTML to check that it contains the error messages, but it is not necessary in our case as we have not customized the form layout.
Now, we need to test the admin bar found on the job preview page. When a job
has not been activated yet, you can edit, delete, or publish the job. To test
those three links, we will need to first create a job. But that's a lot of copy
and paste. As I don't like to waste e-trees, let's add a job creator method in
the JobeetTestFunctional
class:
// 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() ; } // ... }
The createJob()
method creates a job, follows the redirect and returns the
browser to not break the fluent interface. You can also pass an array of values
that will be merged with some default values.
Forcing the HTTP Method of a link
Testing the "Publish" link is now more simple:
$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() ;
If you remember from day 10, the "Publish" link has been configured to be
called with the HTTP ~PUT|PUT (HTTP Method)~
method. As browsers don't understand
PUT
requests, the link_to()
helper converts the link to a form with some
JavaScript. As the test browser does not execute JavaScript, we need to force
the method to PUT
by passing it as a third option of the click()
method.
Moreover, the link_to()
helper also embeds a CSRF token as we have enabled
CSRF protection during day 1; the _with_csrf
option simulates this token.
Testing the "Delete" link is quite similar:
$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 as a SafeGuard
When a job is published, you cannot edit it anymore. Even if the "Edit" link is not displayed anymore on the preview page, let's add some tests for this requirement.
First, add another argument to the createJob()
method to allow automatic
publication of the job, and create a getJobByPosition()
method that returns
a job given its position value:
// 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(); } // ... }
If a job is published, the edit page must return a 404 status code:
$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() ;
But if you run the tests, you won't have the expected result as we forgot to implement this security measure yesterday. Writing tests is also a great way to discover bugs, as you need to think about all edge cases.
Fixing the bug is quite simple as we just need to forward to a 404 page if the job is activated:
// 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); }
The fix is trivial, but are you sure that everything else still works as expected? You can open your browser and start testing all possible combinations to access the edit page. But there is a simpler way: run your test suite; if you have introduced a regression, symfony will tell you right away.
Back to the Future in a Test
When a job is expiring in less than five days, or if it is already expired, the user can extend the job validation for another 30 days from the current date.
Testing this requirement in a browser is not easy as the expiration date is automatically set when the job is created to 30 days in the future. So, when getting the job page, the link to extend the job is not present. Sure, you can hack the expiration date in the database, or tweak the template to always display the link, but that's tedious and error prone. As you have already guessed, writing some tests will help us one more time.
As always, we need to add a new route for the extend
method first:
# apps/frontend/config/routing.yml job: class: sfDoctrineRouteCollection options: model: JobeetJob column: token object_actions: { publish: PUT, extend: PUT } requirements: token: \w+
Then, update the "Extend" link code in the _admin
partial:
<!-- 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; ?>
Then, create the extend
action:
// 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)); }
As expected by the action, the extend()
method of JobeetJob
returns true
if the job has been extended or false
otherwise:
// 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; } // ... }
Eventually, add a test scenario:
$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')) );
This test scenario introduces a few new things:
- The
call()
method retrieves a URL with a method different fromGET
orPOST
- After the job has been updated by the action, we need to reload the local
object with
$job->refresh()
- At the end, we use the embedded
lime
object directly to test the new expiration date.
Forms Security
Form Serialization Magic!
Doctrine forms are very easy to use as they automate a lot of work. For
instance, serializing a form to the database is as simple as a call to
$form->save()
.
But how does it work? Basically, the save()
method follows the following
steps:
- Begin a transaction (because nested Doctrine forms are all saved in one fell swoop)
- Process the submitted values (by calling
updateCOLUMNColumn()
methods if they exist) - Call Doctrine object
fromArray()
method to update the column values - Save the object to the database
- Commit the transaction
Built-in Security Features
The fromArray()
method takes an array of values and updates the corresponding
column values. Does this represent a security issue? What if someone tries to
submit a value for a column for which he does not have authorization? For
instance, can I force the token
column?
Let's write a test to simulate a job submission with a token
field:
// 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() ;
When submitting the form, you must have an extra_fields
global error.
That's because by default forms do not allow extra fields to be present in the
submitted values. That's also why all form fields must have an associated
validator.
tip
You can also submit additional fields from the comfort of your browser using tools like the Firefox Web Developer Toolbar.
You can bypass this security measure by setting the allow_extra_fields
option to true
:
class MyForm extends sfForm { public function configure() { // ... $this->validatorSchema->setOption('allow_extra_fields', true); } }
The test must now pass but the token
value has been filtered out of the
values. So, you are still not able to bypass the security measure. But if you
really want the value, set the filter_extra_fields
option to false
:
$this->validatorSchema->setOption('filter_extra_fields', false);
note
The tests written in this section are only for demonstration purpose. You can now remove them from the Jobeet project as tests do not need to validate symfony features.
XSS and CSRF Protection
During day 1, we created the frontend
application with the following command
line:
$ php symfony generate:app --escaping-strategy=on --csrf-secret=Unique$ecret frontend
The --escaping-strategy
option enables the protection against XSS. It means
that all variables used in templates are escaped by default. If you try to
submit a job description with some HTML tags inside, you will notice that when
symfony renders the job page, the HTML tags from the description are not
interpreted, but rendered as plain text.
The --csrf-secret
option enabled the CSRF protection. When you provide this
option, all forms embed a _csrf_token
hidden field.
tip
The escaping strategy and the CSRF secret can be changed at any time by
editing the apps/frontend/config/settings.yml
configuration
file. As for the databases.yml
file, the settings are configurable by
environment:
all: .settings: # Form security secret (CSRF protection) csrf_secret: Unique$ecret # Output escaping settings escaping_strategy: on escaping_method: ESC_SPECIALCHARS
Maintenance Tasks
Even if symfony is a web framework, it comes with a command line tool. You have already used it to create the default directory structure of the project and the application, but also to generate various files for the model. Adding a new task is quite easy as the tools used by the symfony command line are packaged in a framework.
When a user creates a job, he must activate it to put it online. But if not, the database will grow with stale jobs. Let's create a task that remove stale jobs from the database. This task will have to be run regularly in a 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)); } }
The task configuration is done in the configure()
method. Each task must have
a unique name (namespace
:name
), and can have arguments and options.
tip
Browse the built-in symfony tasks (lib/task/
) for more examples of usage.
The jobeet:cleanup
task defines two options: --env
and --days
with some
sensible defaults.
Running the task is similar to running any other symfony built-in task:
$ php symfony jobeet:cleanup --days=10 --env=dev
As always, the database cleanup code has been factored out in the
JobeetJobTable
class:
// 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.
See you Tomorrow
Testing is at the heart of the symfony philosophy and tools. Today, we have learned again how to leverage symfony tools to make the development process easier, faster, and more important, safer.
The symfony form framework provides much more than just widgets and validators: it gives you a simple way to test your forms and ensure that your forms are secure by default.
Our tour of great symfony features do not end today. Tomorrow, we will create the backend application for Jobeet. Creating a backend interface is a must for most web projects, and Jobeet is no different. But how will we be able to develop such an interface in just one hour? Simple, we will use the symfony admin generator framework. Until then, take care.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.