Testing Secured Pages

Log a user in on the test client, assert a 403 for the wrong role, and test a voter directly without an HTTP request.

Syntax$client->loginUser($user, 'main');

A page behind a login cannot be tested by requesting it: you get the login page. The test client can put a user in the session for you with $client->loginUser($user). Pass the firewall name as the second argument if your application has more than one; the default is main.

Three outcomes, not two

Authorisation failures look different depending on who is asking, and conflating them is the usual mistake:

  • Not logged in, on a firewall with an entry point: a redirect to the login page, status 302. Not 403.
  • Logged in, wrong role or denied by a voter: status 403.
  • Logged in and allowed: the page, status 200.

Write a test for each. The first is the one people skip, and it is the one that catches a route accidentally dropped out of the firewall's pattern.

What loginUser skips

loginUser() builds a token and stores it. It does not run your authenticator, so no password is checked and no login success listener fires. That is what makes it fast, and it also means these tests cannot tell you the login form works. Test the form itself once, by submitting it.

Voters without HTTP

A voter is a class that decides whether a user may do something to a particular object. It is ordinary PHP, so test it directly: construct it, build a token holding a user, and call vote($token, $subject, $attributes). It returns VoterInterface::ACCESS_GRANTED, ACCESS_DENIED or ACCESS_ABSTAIN. One request per permission rule is a slow way to get the same answer.

Example

Example · php
<?php
// tests/Controller/ArticleAdminControllerTest.php

declare(strict_types=1);

namespace App\Tests\Controller;

use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ArticleAdminControllerTest extends WebTestCase
{
    public function testAnAnonymousVisitorIsSentToTheLoginPage(): void
    {
        $client = static::createClient();
        $client->request('GET', '/admin/articles');

        // No user, and the firewall has a login entry point:
        // GET /admin/articles returns 302 to /login, not 403.
        self::assertResponseRedirects('/login');
    }

    public function testAnEditorWithoutTheAdminRoleIsForbidden(): void
    {
        $client = static::createClient();

        // Fetch the service after createClient(), not before: each
        // request reboots the kernel.
        $users = static::getContainer()->get(UserRepository::class);
        $editor = $users->findOneBy(['email' => '[email protected]']);

        $client->loginUser($editor);
        $client->request('GET', '/admin/articles');

        // Logged in but lacking ROLE_ADMIN: 403.
        self::assertResponseStatusCodeSame(403);
    }

    public function testAnAdminSeesTheArticleList(): void
    {
        $client = static::createClient();

        $users = static::getContainer()->get(UserRepository::class);
        $admin = $users->findOneBy(['email' => '[email protected]']);

        $client->loginUser($admin);
        $client->request('GET', '/admin/articles');

        // 200, with the admin heading on the page.
        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h1', 'Manage articles');
    }
}

When to use it

  • Check that an admin route returns 403 for an ordinary signed-in user.
  • Prove a new route has not fallen outside the firewall's pattern.
  • Cover every branch of an ownership voter in a few milliseconds.
  • Verify the login form accepts the correct password and rejects a wrong one.

More examples

A voter tested as plain PHP

No kernel, no database, no request, so each permission rule costs microseconds and you can cover every combination. The second test is the one that matters most in a project with several voters: a voter that denies instead of abstaining on an unrelated subject will veto decisions it was never meant to touch. Note that the third constructor argument of UsernamePasswordToken is the roles array, and the second is the firewall name.

Example · php
<?php
// tests/Security/ArticleVoterTest.php

declare(strict_types=1);

namespace App\Tests\Security;

use App\Entity\Article;
use App\Entity\User;
use App\Security\ArticleVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

final class ArticleVoterTest extends TestCase
{
    public function testOnlyTheAuthorMayEditTheirArticle(): void
    {
        $author = (new User())->setEmail('[email protected]');
        $stranger = (new User())->setEmail('[email protected]');

        $article = new Article();
        $article->setAuthor($author);

        $voter = new ArticleVoter();

        // vote(TokenInterface $token, mixed $subject, array $attributes): int
        $asAuthor = $voter->vote(
            new UsernamePasswordToken($author, 'main', ['ROLE_USER']),
            $article,
            [ArticleVoter::EDIT],
        );

        $asStranger = $voter->vote(
            new UsernamePasswordToken($stranger, 'main', ['ROLE_USER']),
            $article,
            [ArticleVoter::EDIT],
        );

        self::assertSame(VoterInterface::ACCESS_GRANTED, $asAuthor);
        self::assertSame(VoterInterface::ACCESS_DENIED, $asStranger);
    }

    public function testTheVoterIgnoresSubjectsItDoesNotOwn(): void
    {
        $voter = new ArticleVoter();

        $decision = $voter->vote(
            new UsernamePasswordToken(new User(), 'main', ['ROLE_USER']),
            new \stdClass(),
            [ArticleVoter::EDIT],
        );

        // Abstaining, not denying: another voter may still grant access.
        self::assertSame(VoterInterface::ACCESS_ABSTAIN, $decision);
    }
}

// Output:
// OK (2 tests, 3 assertions)

A user built in memory, with no database row

Use this when the page only cares about roles and never loads the user from the database. It keeps the test independent of your fixtures, which is one less reason for it to break. It will not work where the controller or template expects your own User entity, because an InMemoryUser is a different class; there, load a real user.

Example · php
<?php
// tests/Controller/DashboardControllerTest.php

declare(strict_types=1);

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Security\Core\User\InMemoryUser;

final class DashboardControllerTest extends WebTestCase
{
    public function testTheRoleSwitchIsEnoughToReachTheDashboard(): void
    {
        $client = static::createClient();

        // No fixture and no query: the user exists only for this test.
        $client->loginUser(new InMemoryUser('auditor', null, ['ROLE_AUDITOR']));

        $client->request('GET', '/dashboard');

        // GET /dashboard returns 200 for a user holding ROLE_AUDITOR.
        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h1', 'Dashboard');
    }
}

Testing the login form itself

Write these two once per application. They are the only tests that exercise the authenticator, the password hasher and the login redirect, all of which loginUser() bypasses. The password here must match what your user fixture hashed; a hard-coded value in both places is fine and keeps the failure obvious.

Example · php
<?php
// tests/Controller/SecurityControllerTest.php

declare(strict_types=1);

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class SecurityControllerTest extends WebTestCase
{
    public function testTheRightPasswordLogsTheEditorIn(): void
    {
        $client = static::createClient();
        $client->request('GET', '/login');

        // The real authenticator runs here, unlike with loginUser().
        $client->submitForm('Sign in', [
            '_username' => '[email protected]',
            '_password' => 'test-password',
        ]);

        // A successful login answers 302 to the target path.
        self::assertResponseRedirects('/articles');
        $client->followRedirect();
        self::assertResponseIsSuccessful();
    }

    public function testAWrongPasswordComesBackToTheForm(): void
    {
        $client = static::createClient();
        $client->request('GET', '/login');

        $client->submitForm('Sign in', [
            '_username' => '[email protected]',
            '_password' => 'not-the-password',
        ]);

        $client->followRedirect();
        self::assertSelectorTextContains('.alert-danger', 'Invalid credentials');
    }
}

Discussion

  • Be the first to comment on this lesson.