Giorno 11: Testare i Form

Ieri abbiamo creato il nostro primo form con symfony. Gli utenti possono ora inserire una nuova offerta di lavoro si Jobeet, però abbiamo finito il tempo a disposizione prima che potessimo aggiungere alcuni test.

È quello che faremo oggi. Nel mentre impareremo ancora qualcosa sul framework dei form.

Inviare un Form

Aprite il file jobActionsTest per aggiungere i ~test~ per la creazione di un'offerta di lavoro e per il processo di validazione.

Alla fine del file aggiungete il seguente codice per avere la pagina di creazione offerta:

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

Abbiamo già usato il metodo click() per simulare i click sui link. Lo stesso metodo click() può essere usato per inviare un ~form~. Per un form, potete passare i valori da inviare per ogni campo come secondo parametro di un metodo. Come un vero browser, l'oggetto browser si occuperà di fondere i valori di default con i valori inviati dal form.

Ma per inviare i valori dei campi abbiamo bisogno di conoscere i loro nomi. Se aprite il codice sorgente, oppure utilizzate la Web Developer Toolbar di Firefox con la funzione "Forms > Display Form Details", potrete vedere che il nome del campo company è jobeet_job[company].

Quando PHP incontra un campo di input con un nome tipo jobeet_job[company], lo converte automaticamente in un array di nome jobeet_job.

Per far sembrare le cose un po' più semplici, cambiamo il formato a job[%s] aggiungendo il seguente codice alla fine del metodo configure() del JobeetJobForm:

 
 

// lib/form/JobeetJobForm.class.php // lib/form/doctrine/JobeetJobForm.class.php $this->widgetSchema->setNameFormat('job[%s]');

Dopo questa modifica, il nome del campo company dovrebbe essere job[company]. È giunto quindi il momento di cliccare sul pulsante "Preview your job" passando dati validi al 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'        => '[email protected]',
    'is_public'    => false,
  )))->
 
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'create')->
  end()
;
 

Il browser inoltre simula l'~upload di file~, se si passa il percorso assoluto del file da caricare.

Dopo aver inviato il form, abbiamo controllato che l'azione eseguita fosse create.

Il Form Tester

Il form che abbiamo inviato dovrebbe essere valido. Potete testarlo usando il form tester:

with('form')->begin()->
  hasErrors(false)->
end()
 

Il form tester ha diversi metodi per testare lo stato corrente di un form, come per gli errori.

Se fate un errore nel test e il test non passa, potete usare l'istruzione with('response')->~debug~() vista durante il giorno 9. Ma dovrete analizzare l'HTML generato per verificare i messaggi d'errore. Non è molto conveniente. Il form tester mette a disposizione un metodo debug(), che mostra lo stato del form e tutti i messaggi d'errore associati:

with('form')->debug()
 

Test di rinvio

Con un form valido l'offerta di lavoro dovrebbe venire creata e l'utente ~rinviato~ alla pagina show:

with('response')->isRedirected()->
followRedirect()->
 
with('request')->begin()->
  isParameter('module', 'job')->
  isParameter('action', 'show')->
end()
 

isRedirected() verifica se la pagina è stata rinviata e il metodo followRedirect() segue il rinvio.

La classe browser non segue automaticamente i rinvii, poiché si potrebbe voler analizzare gli oggetti prima del rinvio.

Il Tester ##ORM##

Alla fine vorremo verificare che l'offerta di lavoro sia stata creata sul database e verificare che la colonna is_activated sia impostata sul valore false, visto che l'utente non l'ha ancora pubblicata.

Possiamo farlo facilmente utilizzando un altro ~tester~, il **tester

ORM##**. Visto che il tester ##ORM## non è inserito di default,

aggiungiamolo ora:

[php] $browser->setTester('propel', 'sfTesterPropel'); [php] $browser->setTester('doctrine', 'sfTesterDoctrine');

Il tester ##ORM## offre il metodo check() per verificare che uno o più oggetti nel database corrispondano al criterio passato come parametro.

 
 

with('propel')->begin()-> with('doctrine')->begin()-> check('JobeetJob', array( 'location' => 'Atlanta, USA', 'is_activated' => false, 'is_public' => false, ))-> end()

Il criterio può essere un array di valori come qui sopra o un'istanza di Criteria per query più complesse. Potete verificare l'esistenza di oggetti corrispondenti al criterio con un booleano come terzo parametro (il default è true) o il numero di oggetti corrispondenti passando un intero.

Testare gli ~errori~

Il ~form~ per creare i lavori funziona come ci aspettavamo, quando inviamo valori validi. Aggiungiamo un test per verificare il comportamento in caso di invio di dati non validi.

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

Il metodo hasErrors() può testare il numero di errori, se si passa un intero. Il metodo isError() testa il codice di errore per un dato campo.

Nei test che abbiamo scritto per l'invio di dati non validi, non abbiamo ri-testato l'intero form da capo. Abbiamo solo aggiunto dei test per le cose specifiche.

Si possono anche testare le parti di ~HTML~ generato, per verificare che contengano i messaggi di errore, ma non è necessario nel nostro caso, perché non abbiamo un layout personalizzato per il form.

Ora, dobbiamo testare la barra di amministrazione che si trova nella pagina di anteprima del lavoro. Quando un lavoro non è stato ancora attivato, lo si può modificare, cancellare, o pubblicare. Per testare questi link, avremo bisogno di creare un lavoro. Ma è un sacco di copia e incolla. Siccome non ci va di sprecare tempo, aggiungiamo un metodo creatore di lavori nella classe 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'        => '[email protected]',
        'is_public'    => false,
      ), $values)))->
      followRedirect()
    ;
  }
 
  // ...
}
 

Il metodo createJob() crea un lavoro, segue il rinvio e restituisce il browser per non interrompere l'interfaccia fluida. Si può anche passare un array di valori che saranno mescolati con quelli predefiniti.

Forzare il ~metodo HTTP~ di un link

Testare il link "Publish" ora è più facile:

$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()-> with('doctrine')->begin()-> check('JobeetJob', array( 'position' => 'FOO1', 'is_activated' => true, ))-> end() ;

Se ricordate il giorno 10, il link "Publish" è stato configurato per essere richiamato col metodo HTTP ~PUT~. Siccome i browser non capiscono le richieste PUT, l'helper link_to() converte il link in un form con un po' di JavaScript. Siccome il browser dei test non esegue JavaScript, abbiamo bisogno di forzare il metodo PUT passando una terza opzione al metodo click(). Inoltre, l'helper link_to() include anche un ~token CSRF~, visto che abbiamo abilitato la protezione da CSRF nel giorno 1; l'opzione _with_csrf simula questo token.

Il test del link "Delete" è molto simile:

$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()-> with('doctrine')->begin()-> check('JobeetJob', array( 'position' => 'FOO2', ), false)-> end() ;

Test come guardia

Quando un lavoro è pubblicato, non può più essere modificato. Anche se il link "Edit" non si vede più nella pagina di anteprima, aggiungiamo alcuni test per questo requisito.

Prima aggiungiamo un altro parametro al metodo createJob(), per consentire la pubblicazione automatica del lavoro, e creiamo un metodo getJobByPosition() che restituisca un lavoro, dato il suo valore di posizione:

// 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'        => '[email protected]',
        'is_public'    => false,
      ), $values)))->
      followRedirect()
    ;
 
    if ($publish)
    {
      $this->
        click('Publish', array(), array('method' => 'put', '_with_csrf' => true))->
        followRedirect()
      ;
    }
 
    return $this;
  }
 

public function getJobByPosition($position) { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::POSITION, $position);

    return JobeetJobPeer::doSelectOne($criteria);
  }

public function getJobByPosition($position) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.position = ?', $position);

    return $q->fetchOne();
  }

  // ...
}

Se un lavoro è pubblicato, la pagina di modifica deve restituire un codice di errore ~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()
;
 

Ma se si eseguono i test, non si avrà il risultato atteso, perché ieri abbiamo dimenticato di implementare questa misura di ~sicurezza~. Scrivere i test è anche un bel modo di scoprire bug, perché occorre pensare a tutti i casi limite.

Risolvere il bug è molto semplice, basta rimandare a una pagina 404 se il lavoro è attivato:

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

La soluzione è banale, ma siamo sicuri che tutto il resto funzioni ancora come ci aspettiamo? Si può aprire il browser e iniziare a testare tutte le possibili combinazioni di accesso alla pagina di modifica. Ma c'è un modo più semplice: eseguire tutti i test; se è stata introdotta una ~regressione~, symfony lo dirà.

Ritorno al futuro in un test

Quando un lavoro sta scadendo in meno di cinque giorni, o se è già scaduto, l'utente può estenderne la validità per altri 30 giorni dalla data attuale.

Testare questo requisito in un browser non è facile, perché la data è impostata automaticamente, quando il lavoro è creato, a 30 giorni nel futuro. Quindi, quando si prende la pagina del lavoro, il link per estendere il lavoro non è presente. Certo, si può modificare a mano la data di scadenza nel database, o modificare il template per mostrare sempre il link, ma è noioso ed esposto a errori. Come forse avete indovinato, scrivere qualche test ci aiuterà ancora una volta.

Come sempre, prima dobbiamo aggiungere una nuova rotta per il metodo extend:

#

class: sfPropelRouteCollection class: sfDoctrineRouteCollection options: model: JobeetJob column: token object_actions: { publish: PUT, extend: PUT } requirements: token: \w+

Poi aggiorniamo il codice del link "Extend" nel 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; ?>
 

Quindi creiamo l'azione 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.', $job->getExpiresAt('m/d/Y'))); $this->getUser()->setFlash('notice', sprintf('Your job validity has been extended until %s.', $job->getDateTimeObject('expires_at')->format('m/d/Y')));

  $this->redirect('job_show_user', $job);
}

Come ci si potrebbe aspettare dall'azione, il metodo extend() di JobeetJob restituisce true se il lavoro è stato esteso, false altrimenti:

[php] // 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();
  }

  // ...
}

[php] // 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;
  }

  // ...
}

Infine, aggiungiamo uno scenario di 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(time()); $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->reload(); $browser->test()->is( $job->getExpiresAt('y/m/d'), date('y/m/d', time() + 86400 * sfConfig::get('app_active_days')) ); $job->refresh(); $browser->test()->is( $job->getDateTimeObject('expires_at')->format('y/m/d'), date('y/m/d', time() + 86400 * sfConfig::get('app_active_days')) );

Questo scenario di test introduce alcune nuove cose:

  • Il metodo call() recupera un URL con un metodo diverso da GET o POST
  • Dopo che il lavoro è stato aggiornato dall'azione, occorre ricaricare l'oggetto locale con $job->reload()
  • Dopo che il lavoro è stato aggiornato dall'azione, occorre ricaricare l'oggetto locale con $job->refresh()
  • Alla fine, usiamo l'oggetto lime direttamente per testare la nuova data di scadenza.

Sicurezza nei form

Magia nella serializzazione dei form!

I ~form~ ##ORM## sono molto facili da usare, perché automatizzano un sacco di lavoro. Per esempio, serializzare un form nel database è facile quanto richiamare $form->save().

Ma come funziona? Di base, il metodo save() esegue i seguenti passi:

  • Inizia una transazione (perché i form annidati di ##ORM## sono tutti salvati in un colpo solo)
  • Processa i valori inviati (richiamando il metodo updateCOLUMNColumn(), se esiste)
  • Richiama il metodo fromArray() dell'oggetto ##ORM## per aggiornare i valori delle colonne
  • Salva l'oggetto nel database
  • Esegue il commit della transazione

Feature di sicurezza incluse

Il metodo fromArray() accetta un array di valori e aggiorna i valori delle colonne corrispondenti. Questo rappresenta un problema di ~sicurezza~? Che succede se qualcuno prova a inviare un valore per una colonna per cui non ha l'autorizzazione? Per esempio, si può forzare la colonna token?

Scriviamo un test per simulare l'inserimento di un lavoro con un campo 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()
;
 

Quando si invia il form, si deve ricevere un errore globale extra_fields. Questo perché per default i form non consentono campi ulteriori tra i valori inviati. Anche per questo tutti i campi del form devono avere un validatore associato.

Si possono inviare campi addizionali comodamente dal browser, usando strumenti come Web Developer Toolbar per Firefox.

Si può aggirare questa misura di sicurezza impostando l'opzione allow_extra_fields a true:

class MyForm extends sfForm
{
  public function configure()
  {
    // ...
 
    $this->validatorSchema->setOption('allow_extra_fields', true);
  }
}
 

Il test ora deve passare, ma il valore token è stato filtrato ed escluso dai valori. Quindi non si è ancora in grado di aggirare la misura di sicurezza. Ma se si vuole veramente il valore, basta impostare l'opzione filter_extra_fields a false:

$this->validatorSchema->setOption('filter_extra_fields', false);
 

I test scritti in questa sezione hanno solo scopo dimostrativo. Possono essere rimossi dal progetto Jobeet, perché non servono a validare feature di symfony.

Protezione da ~XSS~ e da ~CSRF~

Durante il giorno 1, abbiamo imparato che il task generate:app crea una applicazione sicura per impostazione predefinita.

In primo luogo abilita la protezione da XSS. Vuol dire che tutte le variabili usate nel template subiscono un escaping per impostazione predefinita. Se si prova a inviare la descrizione di un lavoro con alcuni tag HTML dentro, si noterà che quando symfony mostra la pagina del lavoro, i tag HTML della descrizione non sono interpretati, ma mostrati come testo semplice.

Dopo abilita la protezione da CSRF. Quando si fornisce questa opzione, tutti i form includono un campo nascosto _csrf_token.

La strategia di escaping e il segreto CSRF possono essere cambiati in qualsiasi momento modificando il file di configurazione apps/frontend/config/~settings.yml~. Come per il file databases.yml, le impostazioni sono configurabili per ambiente:

---
all:
  .settings:
    # Form security secret (CSRF protection)
    csrf_secret: Unique$ecret

    # Output escaping settings
    escaping_strategy: true
    escaping_method:   ESC_SPECIALCHARS

Task di manutenzione

Anche se symfony è un framework per il web, possiede uno strumento a ~linea di comando~. L'abbiamo già usato per creare la struttura di cartelle di default del progetto e dell'applicazione, ma anche per generare vari file del modello. Aggiungere un nuovo ~task~ è molto semplice, perché gli strumenti usati da symfony sono pacchettizzati in un framework.

Quando un utente crea un lavoro, deve attivarlo per metterlo online. Ma se non lo fa, il database si riempirà di lavori inutili. Creiamo un task che rimuove i lavori inutili. Questo task dovrà girare regolarmente in un 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)); $nb = Doctrine::getTable('JobeetJob')->cleanup($options['days']); $this->logSection('doctrine', sprintf('Removed %d stale jobs', $nb)); } }

La configurazione del task viene fatta nel metodo configure(). Ogni task deve avere un nome univoco (namespace:name) e può avere parametri e opzioni.

Consultate i task predefiniti di symfony (lib/task/) per ulteriori esempi di utilizzo.

Il task jobeet:cleanup definisce due opzioni: --env e --days, con alcuni default sensibili.

Eseguire il task è simile a eseguire ogni altro task predefinito di symfony:

$ php symfony jobeet:cleanup --days=10 --env=dev

Come sempre, il codice pulito per il database è stato fattorizzato nella classe JobeetJobPeer:

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

Il metodo doDelete() rimuove dal database le righe che corrispondono all' oggetto Criteria dato. Può anche accettare un array di chiavi primarie. Come sempre, il codice pulito per il database è stato fattorizzato nella classe 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();
}
 

I task di symfony si comportano bene con i loro ambienti, perché restituiscono un valore in accordo al successo del task. Si può forzare un valore di ritorno, restituendo un intero esplicitamente alla fine del task.

A domani

I test sono nel cuore della filosofia e degli strumenti di symfony. Oggi abbiamo imparato ancora come padroneggiare gli strumenti di symfony per rendere il processo di sviluppo più facile, più veloce e soprattutto più sicuro.

Il framework dei form di symfony fornisce molto più che semplici widget e validatori: dà un modo semplice per testare i form e assicura che i form siano sicuri di default.

Il nostro tour delle grandi feature di symfony non finisce oggi. Domani creeremo l'applicazione di backend per Jobeet. Creare un'interfaccia di backend è un must per la maggior parte dei progetti web e Jobeet non fa differenza. Ma come potremo essere in grado di sviluppare una simile interfaccia in solo un'ora? Semplice, useremo il framework di generazione dell'amministrazione di symfony. Fino ad allora, statemi bene.

ORM

インデックス

Document Index

関連ページリスト

Related Pages

日本語ドキュメント

Japanese Documents

リリース情報
Release Information

Symfony2 に関する情報(公式) Books on symfony