This post was published as part of the symfony 2008 advent calendar. As this tutorial might have been updated since then, you are advised to read the last version from the symfony 1.2 documentation (for Propel or Doctrine).

Previously on Jobeet

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].

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/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_uploads_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,
  )))
;
 

The form must be submitted to the create action:

with('request')->begin()->
  isParameter('module', 'job')->
  isParameter('action', 'create')->
end()->
 

The browser also simulates file uploads by passing the absolute path to the file to upload.

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() 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. So, the form tester also provides a debug() method that output 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.

The Propel 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 Propel tester. As the Propel tester is not registered by default, let's add it now:

$browser->setTester('propel', 'sfTesterPropel');
 

The Propel tester provides the check() method to check that one or more objects in the database matches the criteria passed as an argument.

with('propel')->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 Criteria 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(4)->
    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.

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,
        'type'         => 'full-time',
      ), $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('propel')->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 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('propel')->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));
    }
 
    return $this;
  }
 
  public function getJobByPosition($position)
  {
    $criteria = new Criteria();
    $criteria->add(JobeetJobPeer::POSITION, $position);
 
    return JobeetJobPeer::doSelectOne($criteria);
  }
 
  // ...
}
 

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)
{
  $jobeet_job = $this->getRoute()->getObject();
  $this->form = new JobeetJobForm($jobeet_job);
 
  $this->forward404If($jobeet_job->getIsActivated());
}
 

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:   sfPropelRouteCollection
  options:
    model:          JobeetJob
    column:         token
    object_actions: { publish: PUT, extend: PUT }
  requirements:
    token: \w+
 

Then, update "Extend" link 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 extend until %s.', $job->getExpiresAt('m/d/Y')));
 
  $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/JobeetJob.php
class JobeetJob extends BaseJobeetJob
{
  public function extend()
  {
    if (!$this->expiresSoon())
    {
      return false;
    }
 
    $this->setExpiresAt(time() + 86400 * sfConfig::get('app_active_days'));
 
    return $this->save();
  }
 
  // ...
}
 

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(time());
$job->save();
 
$browser->
  call(sprintf('/job/%s/extend', $job->getToken()), 'put', array('_with_csrf' => true))->
  with('response')->isRedirected()
;
 
$job->reload();
$browser->test()->is(
  $job->getExpiresAt('y/m/d'),
  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 from GET or POST
  • After the job has been updated by the action, we need to reload the local object with $job->reload()
  • At the end, we use the embedded lime object directly to test the new expiration date.

Forms Security

Form Serialization Magic!

Propel 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(). How does it work?

Basically, the save() method does the following steps:

  • Begin a transaction (because nested Propel forms are all saved in one fell swoop)
  • Process the submitted values (by calling updateCOLUMNColumn() methods if they exist)
  • Call Propel 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(8)->
    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.

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);
 

The tests written in this section are only for demonstration purpose. You can now remove them from the Jobeet project as tests do no 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.

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('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 = JobeetJobPeer::cleanup($options['days']);
 
    $this->logSection('propel', 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.

Browse the built-in symfony tasks for (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 JobeetJobPeer class:

// lib/model/JobeetJobPeer.php
static public function cleanup($days)
{
  $criteria = new Criteria();
  $criteria->add(self::IS_ACTIVATED, false);
  $criteria->add(self::CREATED_AT, time() - 86400 * $days, Criteria::LESS_THAN);
 
  return self::doDelete($criteria);
}
 

The doDelete() method removes database records matching the given Criteria object. It can also takes an array of primary keys.

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.

Published in #Tutorials