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 SubscribeBack to security! We need to make sure that you can only make a PUT request to update a CheeseListing
if you are the owner of that CheeseListing
. As a reminder, each CheeseListing
is related to one User
via an $owner
property. Only that User
should be able to update this CheeseListing
.
Let's start by writing a test. In the test class, add public function testUpdateCheeseListing()
with the normal $client = self::createClient()
and $this->createUser()
passing cheeseplease@example.com
and password foo
. Wait, I only want to use createUser()
- we'll log in manually a bit later.
... lines 1 - 9 | |
class CheeseListingResourceTest extends CustomApiTestCase | |
{ | |
... lines 12 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
$client = self::createClient(); | |
$user = $this->createUser('cheeseplease@example.com', 'foo'); | |
... lines 34 - 48 | |
} | |
} |
Notice that the first line of my test is $client = self::createClient()
... even though we haven't needed to use that $client
variable yet. It turns out, making this the first line of every test method is important. Yes, this of course creates a $client
object that will help us make requests into our API. But it also boots Symfony's container, which is what gives us access to the entity manager and all other services. If we swapped these two lines and put $this->createUser()
first... it would totally not work! The container wouldn't be available yet. The moral of the story is: always start with self::createClient()
.
Ok, let's think about this: in order to test updating a CheeseListing
, we first need to make sure there's a CheeseListing
in the database to update! Cool! $cheeseListing = new CheeseListing()
and we can pass a title right here: "Block of Cheddar". Next say $cheeseListing->setOwner()
and make sure this CheeseListing
is owned by the user we just created. Now fill in the last required fields: setPrice()
to $10 and setDescription()
.
... lines 1 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... lines 32 - 34 | |
$cheeseListing = new CheeseListing('Block of cheddar'); | |
$cheeseListing->setOwner($user); | |
$cheeseListing->setPrice(1000); | |
$cheeseListing->setDescription('mmmm'); | |
... lines 39 - 48 | |
} | |
... lines 50 - 51 |
To save, we need the entity manager! Go back to CustomApiTestCase
... and copy the code we used to get the entity manager. Needing the entity manager is so common, let's create another shortcut for it: protected function getEntityManager()
that will return EntityManagerInterface
. Inside, return self::$container->get('doctrine')->getManager()
.
... lines 1 - 9 | |
class CustomApiTestCase extends ApiTestCase | |
{ | |
... lines 12 - 48 | |
protected function getEntityManager(): EntityManagerInterface | |
{ | |
return self::$container->get('doctrine')->getManager(); | |
} | |
} |
Let's use that: $em = $this->getEntityManager()
, $em->persist($cheeseListing)
and $em->batman()
. Kidding. But wouldn't that be awesome? $em->flush()
.
... lines 1 - 9 | |
class CheeseListingResourceTest extends CustomApiTestCase | |
{ | |
... lines 12 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... lines 32 - 39 | |
$em = $this->getEntityManager(); | |
$em->persist($cheeseListing); | |
$em->flush(); | |
... lines 43 - 48 | |
} | |
} |
Great setup! Now... to the real work. Let's test the "happy" case first: let's test that if we log in with this user and try to make a PUT
request to update a cheese listing, we'll get a 200 status code.
Easy peasy: $this->logIn()
passing $client
, the email and password. Now that we're authenticated, use $client->request()
to make a PUT
request to /api/cheeses/
and then the id of that CheeseListing
: $cheeseListing->getId()
.
... lines 1 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... lines 32 - 43 | |
$this->logIn($client, 'cheeseplease@example.com', 'foo'); | |
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [ | |
... line 46 | |
]); | |
... line 48 | |
} | |
... lines 50 - 51 |
For the options, most of the time, the only thing you'll need here is the json
option set to the data you need to send. Let's just send a title
field set to updated
. That's enough data for a valid PUT request.
... lines 1 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... lines 32 - 44 | |
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [ | |
'json' => ['title' => 'updated'] | |
]); | |
... line 48 | |
} | |
... lines 50 - 51 |
What status code will we get back on success? You don't have to guess. Down on the docs... it tells us: 200 on success.
Assert that $this->assertResponseStatusCodeSame(200)
.
... lines 1 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... lines 32 - 47 | |
$this->assertResponseStatusCodeSame(200); | |
} | |
... lines 50 - 51 |
Perfect start! Copy the method name so we can execute just this test. At your terminal, run:
php bin/phpunit --filter=testUpdateCheeseListing
And... above those deprecation warnings... yes! It works.
But.. that's no surprise! We haven't really tested the security case we're worried about. What we really want to test is what happens if I login and try to edit a CheeseListing
that I do not own. Ooooo.
Rename this $user
variable to $user1
, change the email to user1@example.com
and update the email below on the logIn()
call. That'll keep things easier to read... because now I'm going to create a second user: $user2 = $this->createUser()
with user2@example.com
and the same password.
... lines 1 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... line 32 | |
$user1 = $this->createUser('user1@example.com', 'foo'); | |
$user2 = $this->createUser('user2@example.com', 'foo'); | |
... lines 35 - 36 | |
$cheeseListing->setOwner($user1); | |
... lines 38 - 50 | |
$this->logIn($client, 'user1@example.com', 'foo'); | |
... lines 52 - 55 | |
} | |
... lines 57 - 58 |
Now, copy the entire login, request, assert-response-status-code stuff and paste it right above here: before we test the "happy" case where the owner tries to edit their own CheeseListing
, let's first see what happens when a non-owner tries this.
Log in this time as user2@example.com
. We're going to make the exact same request, but this time we're expecting a 403
status code, which means we are logged in, but we do not have access to perform this operation.
... lines 1 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... lines 32 - 44 | |
$this->logIn($client, 'user2@example.com', 'foo'); | |
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [ | |
'json' => ['title' => 'updated'] | |
]); | |
$this->assertResponseStatusCodeSame(403, 'only author can updated'); | |
... lines 50 - 55 | |
} | |
... lines 57 - 58 |
I love it! With any luck, this should fail: our access_control
is not smart enough to prevent this yet. Try the test:
php bin/phpunit --filter=testUpdateCheeseListing
And... yes! We expected a 403 status code but got back 200.
Ok, let's fix this!
The access_control
option - which will probably be renamed to security
in API Platform 2.5 - allows you to write an "expression" inside using Symfony's expression language. This is_granted()
thing is a function that's available in that, sort of, Twig-like expression language.
We can make this expression more interesting by saying and
to add more logic. API Platform gives us a few variables to work with inside the expression, including one that represents the object we're working with on this operation... in other words, the CheeseListing
object. That variable is called... object
! Another is user
, which is the currently-authenticated User
or null
if the user is anonymous.
Knowing that, we can say and object.getOwner() == user
.
... lines 1 - 16 | |
/** | |
* @ApiResource( | |
* itemOperations={ | |
... lines 20 - 22 | |
* "put"={"access_control"="is_granted('ROLE_USER') and object.getOwner() == user"}, | |
... line 24 | |
* }, | |
... lines 26 - 36 | |
* ) | |
... lines 38 - 47 | |
*/ | |
class CheeseListing | |
... lines 50 - 208 |
Yea... that's it! Try the test again and...
php bin/phpunit --filter=testUpdateCheeseListing
It passes! I told you the security part of this was going to be easy! Most of the work was the test, but I love that I can prove this works.
While we're here, there's one other related option called access_control_message
. Set this to:
only the creator can edit a cheese listing
... and make sure you have a comma after the previous line.
... lines 1 - 16 | |
/** | |
* @ApiResource( | |
* itemOperations={ | |
... lines 20 - 22 | |
* "put"={ | |
* "access_control"="is_granted('ROLE_USER') and object.getOwner() == user", | |
* "access_control_message"="Only the creator can edit a cheese listing" | |
* }, | |
... line 27 | |
* }, | |
... lines 29 - 39 | |
* ) | |
... lines 41 - 50 | |
*/ | |
class CheeseListing | |
... lines 53 - 211 |
If you run the test... this makes no difference. But this option did just change the message the user sees. Check it out: after the 403 status code, var_dump()
$client->getResponse()->getContent()
and pass that false
. Normally, if you call getContent()
on an "error" response - a 400 or 500 level response - it throws an exception. This tells it not to, which will let us see that response's content. Try the test:
... lines 1 - 9 | |
class CheeseListingResourceTest extends CustomApiTestCase | |
{ | |
... lines 12 - 29 | |
public function testUpdateCheeseListing() | |
{ | |
... lines 32 - 49 | |
var_dump($client->getResponse()->getContent(true)); | |
... lines 51 - 56 | |
} | |
} |
php bin/phpunit --filter=testUpdateCheeseListing
The hydra:title
says "An error occurred" but the hydra:description
says:
only the creator can edit a cheese listing.
So, the access_control_message
is a nice way to improve the error your user sees. By the way, in API Platform 2.5, it'll probably be renamed to security_message
.
Remove the var_dump()
. Next, there's a bug in our security! Ahh!!! It's subtle. Let's find it and squash it!
Again clearing cache helped. This time I forgot to set the cache cleaning in the test environment:
php bin/console cache:clear --env=test
Issue closed.
Hi, i'm strugling with this "roles" update.
On the ressource ( named Pilot )
#[ApiResource(
security: "is_granted('ROLE_USER')",
operations: [
new GetCollection(
security: "is_granted('ROLE_ADMIN')"
),
new Get(
security: "object == user or is_granted('ROLE_ADMIN')"
),
new Post(
security: "is_granted('ROLE_ADMIN')",
validationContext: ['groups' => ['Default', 'Create']]
),
new Delete(
security: "is_granted('ROLE_ADMIN')"
),
new Put(
security: "is_granted('ROLE_ADMIN') or object == user"
)
],
normalizationContext: ['groups' => 'pilot:read'],
denormalizationContext: ['groups' => 'pilot:write'],
)]
on the property this .
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['pilot:read', 'pilot:write'])]
private array $roles = [];
And i dont understand why this test
public function testPilotCannotChangeItsRole()
{
$client = self::createClient();
$pilotUser = $this->createAndLoginUser($client, 'user@test.fr', 'password');
$client->request('PUT', $this->PREFIX('/pilots/' . $pilotUser->getId()), [
'headers' => [
'accept' => 'application/json+ld',
],
'json' => [
'roles' => ['ROLE_ADMIN']
]
]);
$this->assertResponseStatusCodeSame(401);
}
is failing ( and i should test 403 , not 401 but that does not change my problem :) )
1) App\Test\Functionnal\PilotsTest::testPilotCannotChangeItsRole
Failed asserting that the Response status code is 401.
HTTP/1.1 200 OK
Cache-Control: max-age=0, must-revalidate, private
Content-Location: /api/v1/pilots/1ed4f72e-61a2-66f0-9ca4-953b1497bc05
Content-Type: application/json+ld; charset=utf-8
Date: Wed, 19 Oct 2022 05:57:22 GMT
Expires: Wed, 19 Oct 2022 05:57:22 GMT
Link: <http://localhost/api/v1/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"
Vary: Accept
X-Content-Type-Options: nosniff
X-Frame-Options: deny
X-Robots-Tag: noindex
{"@context":"\/api\/v1\/contexts\/Pilot","@id":"\/api\/v1\/pilots\/1ed4f72e-61a2-66f0-9ca4-953b1497bc05","@type":"Pilot","id":"1ed4f72e-61a2-66f0-9ca4-953b1497bc05","email":"user@test.fr","roles":["ROLE_USER"],"name":"user"}
Strange thing is the context returned is not showing this 200 ( still ROLE_USER )
Hey Cerpo!
Yea, this stuff is complex! But, hmm. This is, indeed a bit odd. From my perspective, I WOULD expect this situation to be successful. Your Put
operation security is is_granted('ROLE_ADMIN') or object == user
. So that should pass since the user is editing their own record. You have that same security on the property itself, so I would also expect that to work. Why are you expecting it to fail with 401/403?
But, if I'm correct, then we would expect that the roles
WOULD update in the response, which you correctly noted did NOT happen.
So, I'm not sure what's happening. However, I do have one point that might help: If the PUT
operation security "passes", but the ApiProperty
security "fails", I think (I am not 100% sure - this is a bit of a guess as I haven't used the property-level security yet) that the result would be a 200 status code, except that the roles
property would "skip" being set. That feels a lot like what you're experiencing.
Let me know if this helps :)
Cheers!
Hi Ryan, Thanks for the quick answer.
My goal is
but reading you comment i can see that something's wrong with my decoration .
it should says
Voters ?
I think it's time the "Re-watch" .. sometimes details suddenly become obvious !
Quick update
PHPUnit 9.5.25 #StandWithUkraine
Testing
............ 12 / 12 (100%)
Time: 00:01.204, Memory: 54.50 MB
OK (12 tests, 36 assertions)
I had some answers in the next chapters. ( AdminGroupsContextBuilder and phone / roles example ) . Both on a way to handle this and the explanation on code return 200, property ignored etc..
Thanks
Hey Ricardo M.!
Thanks for posting this - I appreciate it :).I I'll add a note to mention this... I think in the next chapter when we mention security_post_denormalize
. Because, basically, if you use security_post_denormalize
(which we talk about in the next chapter), then you need to know to also use security_post_denormalize_message
.
Cheers!
Hi there
i have created API platform 2.6 app and I'm using tests that extends ApiTestCase, so i'm creating some entities before POST call, but that EntityManager in that POST call don't "see" those new entites thus error: Item not found
Hey Daniel,
What do you do exactly when see that "Item not found" error? Are you sure the entities were stored in the database but entity manager does not see it? Can you go to the DB and make sure they really *are* in the database? Btw, in some cases in tests you need to call $entityManager->refresh() and pass an entity to it to sync changes in the DB with the object, otherwise entity manager won't see the changes.
I hope this helps!
Cheers!
Great tutorial, thank you.
I am trying all this out in an excisting api. Is there a way to turn foreign_key_checks of while testing? I use ReloadDatabaseTrait, but when i create a new record of an entity (e.g. new Contact), and i run phpunit, i get ' Cannot add or update a child row: a foreign key constraint fails '.
Thanks in advance.
Hey Annemieke,
"Cannot add or update a child row: a foreign key constraint fails" means that you're trying to input some new data into the DB but it conflicts with the data you already have in your DB. Please, check your DB (I suppose you need to look into test database as you said you run phpunit) and see what data you already have there and what data you're trying to input executing your test. It sounds like you need to empty your DB, i.e. clear the old data before filling it with new data.
Cheers!
Thank you Victor for your quick response. I hope you get payed well for this job.
I found the solution.
In my entity i <b>now</b> use @ORM\Table(name='mytable')
.
Before i used @ORM\Table(name=<b>mydatabase</b>.mytable)
I am indeed using the test database for test, but because of the orm\table with the database name in it, that won't work.
Thank you !!
Hey Annemieke,
I'm happy you figured out the problem and were able to fix it, well done!
Cheers!
Hi
I have noticed that in the current documentation , the entity properties are declared as public , but when I generate entities the properties are declared as private.
In the begining I have no problem, but now when I tried to protect an object with "object.owner == user" , I get this error
"hydra:description": "Cannot access private property App\\Entity\\Book::$owner",
But when I make $owner as public or call getOwner() as you did "object.getOwner() == user", it works good.
Can you please tell us if declaring properties as public like they did in the documentation is dangerous for security or not ?
Thanks expert ;)
Hey hous04!
Short answer: make your properties private. I believe the main reason that you sometimes see public properties used in the API Platform docs is nothing more than... it's easier/shorter to write documentation using public properties than to use private properties and show the getters/setters. Private properties are better just because they're better from an object-oriented perspective.
In the begining I have no problem, but now when I tried to protect an object with "object.owner == user" , I get this error
"hydra:description": "Cannot access private property App\Entity\Book::$owner",
That's interesting. You shouldn't get that error as long as you have a getOwner()
method. API Platform is smart enough to access a property directly if it's public or to use a getOwner()
method if the property is not public and that method exists. That's exactly what we do in this tutorial: my properties are private, but I have the getter method.
Let me know if that helps - or if you still get the error after adding the getter.
Cheers!
Hi
"Let me know if that helps - or if you still get the error after adding the getter"
Yes expert, it's very helpful and good explication, thank you very much ;)
Thank you for this great tutorial!
I have a small feedback: I'd use `previous_object` rather than `object` in the is_granted expression.
While using object, a user could replace the owner field with their own url/id and "steal" the cheese listing.
What do you think?
Hey Paul Molin!
You're 100% correct - good attention to detail. We change to previous_object
and talk about this in the next chapter :).
Cheers!
For some reason my test fails with error message The content-type "application/x-www-form-urlencoded" is not supported.
. In fact all my tests fail with this message. I just ran composer update
before testing, could it be that this broke something? Setting 'headers' => ['Content-Type' => 'application/json']
doesn't seem to do anything....
Hey Tobias I.
That's odd. Is it possible that something is doing an extra request in the middle of your test? Try detecting the request that is failing
Also, can you show me the piece of code where you perform the request?
Cheers!
I tried to reproduce on a fresh symfony project. I only created a user entity via the bin/console make:user
command and followed this this tutorial on setting up the test environment. This is my test class:
`
namespace App\Tests\Functional;
use App\ApiPlatform\Test\ApiTestCase;
class UserResourceTest extends ApiTestCase
{
public function testPostUser()
{
$client = self::createClient();
$client->request('POST', '/api/users', [
'headers' => ['Content-Type' => 'application/json'],
'json' => [],
]);
$this->assertResponseStatusCodeSame(400);
}
}
`
When I now run bin/phpunit
, I again get the same error (here is a <a href="https://puu.sh/EavYl/ab8557ca5e.png">screenshot</a>)
composer show api-platform/core
says that I have version 2.4.6, so the 415 status code is expected. Since 2.4.6 you get a 415 instead of 406 when a faulty content type is used as stated in the <a href="https://github.com/api-platform/core/releases/tag/v2.4.6">release notes</a>. I am not sure what broke, but it's probably only in the test environment because the swagger ui gives me the corrent response and status code
Hey Tobias I.
I found the reason of this problem. You can see my answer here: https://symfonycasts.com/sc...
Cheers!
// composer.json
{
"require": {
"php": "^7.1.3, <8.0",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^2.1", // v2.4.5
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^1.6", // 1.11.2
"doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
"doctrine/orm": "^2.4.5", // v2.7.2
"nelmio/cors-bundle": "^1.5", // 1.5.6
"nesbot/carbon": "^2.17", // 2.21.3
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
"symfony/asset": "4.3.*", // v4.3.2
"symfony/console": "4.3.*", // v4.3.2
"symfony/dotenv": "4.3.*", // v4.3.2
"symfony/expression-language": "4.3.*", // v4.3.2
"symfony/flex": "^1.1", // v1.18.7
"symfony/framework-bundle": "4.3.*", // v4.3.2
"symfony/http-client": "4.3.*", // v4.3.3
"symfony/monolog-bundle": "^3.4", // v3.4.0
"symfony/security-bundle": "4.3.*", // v4.3.2
"symfony/twig-bundle": "4.3.*", // v4.3.2
"symfony/validator": "4.3.*", // v4.3.2
"symfony/webpack-encore-bundle": "^1.6", // v1.6.2
"symfony/yaml": "4.3.*" // v4.3.2
},
"require-dev": {
"hautelook/alice-bundle": "^2.5", // 2.7.3
"symfony/browser-kit": "4.3.*", // v4.3.3
"symfony/css-selector": "4.3.*", // v4.3.3
"symfony/maker-bundle": "^1.11", // v1.12.0
"symfony/phpunit-bridge": "^4.3", // v4.3.3
"symfony/stopwatch": "4.3.*", // v4.3.2
"symfony/web-profiler-bundle": "4.3.*" // v4.3.2
}
}
Hi,
I have an issue with test which checks if cheese listing can be changed via put method by user which isn't its owner. In response I have always 200 status code instead 403. It seems that settings for PUT operation are ignored and I don't know why (by ignoring I mean that I can write everything, e.g.
"blablabla"="is_granted('ROLE_USER') and object.getOwner() == user"
instead of"security"="is_granted('ROLE_USER') and object.getOwner() == user"
and no errors I'll have). I don't know what's going on. Please help me. I'm using API Platform 2.7 and Symfony 4.4Jakub