If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe've got things set up so that only the owner of a treasure can edit it. Now, a new requirement has come down from on-high: admin users should be able to edit any treasure. That means a user that has ROLE_ADMIN
.
To the test-mobile! Add a public function testAdminCanPatchToEditTreasure()
. Then create an admin user with UserFactory::createOne()
passing roles set to ROLE_ADMIN
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 138 | |
public function testAdminCanPatchToEditTreasure(): void | |
{ | |
$admin = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]); | |
} | |
} |
That'll work fine. But if we need to create a lot of admin users in our tests, we can add a shortcut to Foundry. Open UserFactory
. We're going to create something called a "state" method. Anywhere inside, add a public function called, how about withRoles()
that has an array $roles
argument and returns self
, which will make this more convenient when we use it. Then return $this->addState(['roles' => $roles])
:
... lines 1 - 30 | |
final class UserFactory extends ModelFactory | |
{ | |
... lines 33 - 54 | |
public function withRoles(array $roles): self | |
{ | |
return $this->addState(['roles' => $roles]); | |
} | |
... lines 59 - 92 | |
} |
Whatever we pass to addState()
becomes part of the data that will be used to make this user.
To use the state method, the code changes to UserFactory::new()
. Instead of creating a User
object, this instantiates a new UserFactory
... and then we can call withRoles()
and pass ROLE_ADMIN
:
So, we're "crafting" what we want the user to look like. When we're done, call create()
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 138 | |
public function testAdminCanPatchToEditTreasure(): void | |
{ | |
$admin = UserFactory::new()->withRoles(['ROLE_ADMIN'])->create(); | |
} | |
} |
createOne()
is a static shortcut method. But since we have an instance of the factory, use create()
.
But we can go even further. Back in UserFactory
, add another state method called asAdmin()
that returns self
. Inside return $this->withRoles(['ROLE_ADMIN'])
:
... lines 1 - 30 | |
final class UserFactory extends ModelFactory | |
{ | |
... lines 33 - 59 | |
public function asAdmin(): self | |
{ | |
return $this->withRoles(['ROLE_ADMIN']); | |
} | |
... lines 64 - 97 | |
} |
Thanks to that, we can simplify to UserFactory::new()->asAdmin()->create()
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 138 | |
public function testAdminCanPatchToEditTreasure(): void | |
{ | |
$admin = UserFactory::new()->asAdmin()->create(); | |
} | |
} |
Nice!
Now let's get this test going. Create a new $treasure
set to DragonTreasureFactory::createOne()
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 138 | |
public function testAdminCanPatchToEditTreasure(): void | |
{ | |
$admin = UserFactory::new()->asAdmin()->create(); | |
$treasure = DragonTreasureFactory::createOne(); | |
... lines 143 - 153 | |
} | |
} |
Because we're not passing an owner
, this will create a new User
in the background and use that as the owner
. This means that our admin user will not be the owner.
Now, $this->browser()->actingAs($adminUser)
then ->patch()
to /api/treasures/
, $treasure->getId()
, sending json
to update value
to the same 12345
. ->assertStatus(200)
and assertJsonMatches()
, value
, 12345
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 138 | |
public function testAdminCanPatchToEditTreasure(): void | |
{ | |
$admin = UserFactory::new()->asAdmin()->create(); | |
$treasure = DragonTreasureFactory::createOne(); | |
$this->browser() | |
->actingAs($admin) | |
->patch('/api/treasures/'.$treasure->getId(), [ | |
'json' => [ | |
'value' => 12345, | |
], | |
]) | |
->assertStatus(200) | |
->assertJsonMatches('value', 12345) | |
; | |
} | |
} |
Cool! Copy the method name. Let's try it:
symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure
And... okay! We haven't implemented this yet, so it fails.
So, how do we allow admins to edit any treasure? Well, at first, it's relatively easy because we have total control via the security
expression. So we can add something like if is_granted("ROLE_ADMIN") OR
and then put parentheses around the other use-case:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 40 | |
new Patch( | |
security: 'is_granted("ROLE_ADMIN") or (is_granted("ROLE_TREASURE_EDIT") and object.getOwner() == user)', | |
... line 43 | |
), | |
... lines 45 - 47 | |
], | |
... lines 49 - 65 | |
) | |
... lines 67 - 87 | |
class DragonTreasure | |
{ | |
... lines 90 - 247 | |
} |
Let's make sure it works!
symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure
A 500 error! Let's see what's going on. Click to open this.
Unexpected token "name" around position 26.
So... that was an accident. Change OR
to or
. And... also move this new logic into securityPostDenormalize
:
... lines 1 - 27 | |
( | |
... lines 29 - 30 | |
operations: [ | |
... lines 32 - 40 | |
new Patch( | |
security: 'is_granted("ROLE_ADMIN") or (is_granted("ROLE_TREASURE_EDIT") and object.getOwner() == user)', | |
securityPostDenormalize: 'is_granted("ROLE_ADMIN") or object.getOwner() == user', | |
), | |
... lines 45 - 47 | |
], | |
... lines 49 - 65 | |
) | |
... lines 67 - 87 | |
class DragonTreasure | |
{ | |
... lines 90 - 247 | |
} |
Then try the test again:
symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure
Got it! But my screw-up brings up a great point: the security
expression is getting too complex. It's about as readable as a single-line PERL script... and we do not want to make mistakes when it comes to security.
So next, let's centralize this logic with a voter.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}