Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

First of all: Contributions are very welcome!

## Assertion Naming Conventions

When adding new assertions, prefer these conventions:

- Use `see*` / `dontSee*` for high-level, actor-style checks (Codeception style), where the method expresses an observed application state.
- Use `assert*` for direct value/constraint comparisons that map closely to PHPUnit/Symfony assertion semantics.

An assertion should describe what the application does: a request, a service, a message,
a security decision. Checks about the machine the application runs on (writable directories,
project layout, build artifacts, `.env` files) belong in CI or a healthcheck, not in this module.

Container parameters are already reachable through `grabParameter()`, so a value that can be
asserted with `grabParameter()` plus a plain PHPUnit assertion does not need a dedicated method.

**Does your change require a test?**

## No, my change does not require a test
Expand Down
37 changes: 37 additions & 0 deletions src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Codeception\Module\Symfony;

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Tools\SchemaValidator;
use PHPUnit\Framework\Assert;
use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector;

Expand Down Expand Up @@ -166,6 +167,42 @@ public function seeNumRecords(int $expectedNum, string $className, array $criter
);
}

/**
* Asserts that the Doctrine mapping is valid and that the database schema is in sync with it.
*
* In-process equivalent of `bin/console doctrine:schema:validate`: it catches mapping mistakes
* and missing migrations before they surface as unrelated failures in other tests.
*
* The entity manager checked is the one configured in the module's `em_service` option.
*
* ```php
* <?php
* $I->seeDoctrineSchemaIsValid();
* ```
*/
public function seeDoctrineSchemaIsValid(): void
{
if (!class_exists(SchemaValidator::class)) {
Assert::fail("The 'seeDoctrineSchemaIsValid' assertion requires the 'doctrine/orm' package.");
}

$validator = new SchemaValidator($this->_getEntityManager());

$mappingErrors = [];
foreach ($validator->validateMapping() as $className => $classErrors) {
$mappingErrors[] = sprintf(' - %s: %s', $className, implode('; ', $classErrors));
}

if ($mappingErrors !== []) {
Assert::fail(sprintf("The Doctrine mapping is invalid:\n%s", implode("\n", $mappingErrors)));
}

$this->assertTrue(
$validator->schemaInSyncWithMetadata(),
'The database schema is not in sync with the current mapping. A migration is missing.'
);
}

private function grabDoctrineCollector(string $function): DoctrineDataCollector
{
if (!class_exists(DoctrineDataCollector::class)) {
Expand Down
48 changes: 48 additions & 0 deletions src/Codeception/Module/Symfony/SecurityAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

use function get_debug_type;
use function sprintf;

trait SecurityAssertionsTrait
Expand Down Expand Up @@ -131,6 +132,42 @@ public function seeUserPasswordDoesNotNeedRehash(?UserInterface $user = null): v
$this->assertFalse($this->grabPasswordHasherService()->needsRehash($userToValidate), 'User password needs rehash.');
}

/**
* Checks that the current user is granted an attribute, optionally over a subject.
*
* This runs the application's voters through `Security::isGranted()`, so unlike
* `seeUserHasRole()` it also covers custom voters and permissions that depend on
* the object being accessed.
*
* ```php
* <?php
* $I->seeUserIsGranted('POST_EDIT', $post);
* ```
*/
public function seeUserIsGranted(string $attribute, mixed $subject = null): void
{
$this->assertTrue(
$this->grabSecurityService()->isGranted($attribute, $subject),
$this->buildIsGrantedMessage('is not granted', $attribute, $subject)
);
}

/**
* Checks that the current user is not granted an attribute, optionally over a subject.
*
* ```php
* <?php
* $I->dontSeeUserIsGranted('POST_DELETE', $post);
* ```
*/
public function dontSeeUserIsGranted(string $attribute, mixed $subject = null): void
{
$this->assertFalse(
$this->grabSecurityService()->isGranted($attribute, $subject),
$this->buildIsGrantedMessage('is granted', $attribute, $subject)
);
}

private function getAuthenticatedUser(): UserInterface
{
return $this->grabSecurityService()->getUser() ?? Assert::fail('No user found in session to perform this check.');
Expand All @@ -154,4 +191,15 @@ protected function grabPasswordHasherService(): UserPasswordHasherInterface
/** @var UserPasswordHasherInterface */
return $this->grabService('security.password_hasher');
}

private function buildIsGrantedMessage(string $verb, string $attribute, mixed $subject): string
{
return sprintf(
'User %s %s "%s"%s.',
$this->grabSecurityService()->getUser()?->getUserIdentifier() ?? 'anon.',
$verb,
$attribute,
$subject === null ? '' : sprintf(' on %s', get_debug_type($subject))
);
}
}
5 changes: 5 additions & 0 deletions tests/DoctrineAssertionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,9 @@ public function testSeeNumRecords(): void
{
$this->seeNumRecords(1, User::class);
}

public function testSeeDoctrineSchemaIsValid(): void
{
$this->seeDoctrineSchemaIsValid();
}
}
18 changes: 18 additions & 0 deletions tests/SecurityAssertionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\BrowserKit\Cookie;
use Tests\App\Entity\User;
use Tests\App\Security\UserVoter;
use Tests\Support\CodeceptTestCase;

final class SecurityAssertionsTest extends CodeceptTestCase
Expand Down Expand Up @@ -62,6 +63,23 @@ public function testSeeUserPasswordDoesNotNeedRehash(): void
$this->seeUserPasswordDoesNotNeedRehash();
}

public function testSeeUserIsGranted(): void
{
$user = $this->createTestUser(['ROLE_USER']);
$this->client->loginUser($user);

$this->seeUserIsGranted('ROLE_USER');
$this->seeUserIsGranted(UserVoter::EDIT, $user);
}

public function testDontSeeUserIsGranted(): void
{
$this->client->loginUser($this->createTestUser(['ROLE_USER']));

$this->dontSeeUserIsGranted('ROLE_ADMIN');
$this->dontSeeUserIsGranted(UserVoter::EDIT, User::create('jane_doe@gmail.com', 'secret'));
}

private function createTestUser(array $roles): User
{
return User::create('john_doe@gmail.com', $this->grabPasswordHasherService()->hashPassword(User::create('tmp', ''), '123456'), $roles);
Expand Down
36 changes: 36 additions & 0 deletions tests/_app/Security/UserVoter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Tests\App\Security;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Tests\App\Entity\User;

use function in_array;

/**
* Implements VoterInterface instead of extending Voter, whose abstract method signature
* changed across the Symfony versions supported by this module.
*/
final class UserVoter implements VoterInterface
{
public const EDIT = 'USER_EDIT';

/**
* @param mixed[] $attributes
*/
public function vote(TokenInterface $token, mixed $subject, array $attributes, mixed ...$args): int
{
if (!in_array(self::EDIT, $attributes, true) || !$subject instanceof User) {
return self::ACCESS_ABSTAIN;
}

$user = $token->getUser();

return $user instanceof User && $user->getUserIdentifier() === $subject->getUserIdentifier()
? self::ACCESS_GRANTED
: self::ACCESS_DENIED;
}
}
3 changes: 3 additions & 0 deletions tests/_app/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use Tests\App\Repository\UserRepository;
use Tests\App\Repository\UserRepositoryInterface;
use Tests\App\Security\TestUserProvider;
use Tests\App\Security\UserVoter;
use Twig\Extension\ProfilerExtension;
use Twig\Profiler\Profile;

Expand Down Expand Up @@ -61,6 +62,8 @@
->arg('$repository', service(UserRepository::class))
->tag('security.user_provider');

$services->set(UserVoter::class);

$services->alias('security.password_hasher', 'security.user_password_hasher')->public();

if (class_exists(Security::class)) {
Expand Down