Test
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
...
@Test
@DirtiesContext
void shouldUpdateAnExistingCashCard() {
CashCard cashCardUpdate = new CashCard(null, 19.99, null);
HttpEntity<CashCard> request = new HttpEntity<>(cashCardUpdate);
ResponseEntity<Void> response = restTemplate
.withBasicAuth("sarah1", "abc123")
.exchange("/cashcards/99", HttpMethod.PUT, request, Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
// Validte that the resource was updated
ResponseEntity<String> getResponse = restTemplate
.withBasicAuth("sarah1", "abc123")
.getForEntity("/cashcards/99", String.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
DocumentContext documentContext = JsonPath.parse(getResponse.getBody());
Number id = documentContext.read("$.id");
Double amount = documentContext.read("$.amount");
assertThat(id).isEqualTo(99);
assertThat(amount).isEqualTo(19.99);
}
restTemplate.exchange()
All other tests use RestTemplate.xyzForEntity()
methods such as getForEntity()
and postForEntity()
. So, why are we not following the same pattern utilizing putForEntity()
?
Answer: putForEntity()
does not exist! Read more about it here in the GitHub issue about the topic.
Luckily RestTemplate
supports multiple ways of interacting with REST APIs, such as RestTemplate.exchange()
. The exchange()
method is a more general version of the xyzForEntity()
methods we've used in other tests: exchange()
requires the verb and the request entity (the body of the request) to be supplied as parameters. The two lines below are functionally equivalent of one another:
-
.exchange("/cashcards/99", HttpMethod.GET, new HttpEntity(null), String.class);
-
`.getForEntity("/cashcards/99", String.class);`
Now let's explain the test code.
- First we create the
HttpEntity
that theexchange()
method needs:
HttpEntity<CashCard> request = new HttpEntity<>(existingCashCard);
- Then we call
exchange()
, which sends aPUT
request for the target ID of99
and updated Cash Card data:
.exchange("/cashcards/99", HttpMethod.PUT, request, Void.class);
- And finally, we are using
.getForEntity()
to ensure that the requested resource was updated.
Run the test.
[~/exercises] $ ./gradlew test
...
CashCardApplicationTests > shouldUpdateAnExistingCashCard() FAILED
org.opentest4j.AssertionFailedError:
expected: 204 NO_CONTENT
but was: 403 FORBIDDEN
...
BUILD FAILED in 6s
It has failed with a 403 FORBIDDEN
response code, because we haven't implemented a PUT
request method handler yet. With no Controller endpoint, this PUT
call is forbidden! Spring Security automatically handled this scenario for us.
Implement @PutMapping in the Controller
@PutMapping("/{requestedId}")
private ResponseEntity<Void> putCashCard(@PathVariable Long requestedId, @RequestBody CashCard cashCardUpdate, Principal principal) {
CashCard cashCard = cashCardRepository.findByIdAndOwner(requestedId, principal.getName());
CashCard updatedCashCard = new CashCard(cashCard.id(), cashCardUpdate.amount(), principal.getName());
cashCardRepository.save(updatedCashCard);
return ResponseEntity.noContent().build();
}
This Controller endpoint is fairly self-explanatory:
-
The
@PutMapping
supports thePUT
verb and supplies the targetrequestedId
. -
The
@RequestBody
contains the updatedCashCard
data. -
We've added the
Principal
as a method argument, provided automatically by Spring Security. Thanks once again, Spring Security! -
Than we scope our retrieval of the
CashCard
to the submittedrequestedId
andPrincipal
(provided by Spring Security) to ensure only the authenticated, authorizedowner
may update thisCashCard
cashCardRepository.findByIdAndOwner(requestedId, principal.getName());
-
Build a
CashCard
with updated values and save it. -
Return an HTTP
204 NO_CONTENT
response code for now just to get started.
But what happens if we attempt to update a CashCard
that does not exist?
Update non existing resource?
Test
@Test
void shouldNotUpdateACashCardThatDoesNotExist() {
CashCard unknownCard = new CashCard(null, 19.99, null);
HttpEntity<CashCard> request = new HttpEntity<>(unknownCard);
ResponseEntity<Void> response = restTemplate
.withBasicAuth("sarah1", "abc123")
.exchange("/cashcards/99999", HttpMethod.PUT, request, Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
Run the tests and observe the results.
...
CashCardApplicationTests > shouldNotUpdateACashCardThatDoesNotExist() FAILED
org.opentest4j.AssertionFailedError:
expected: 404 NOT_FOUND
but was: 403 FORBIDDEN
Before we run them again, let's edit build.gradle
to enable additional test output.
test {
testLogging {
...
// Change to `true` for more verbose test output
showStandardStreams = true
}
}
After rerunning the tests, search the output for the following:
CashCardApplicationTests > shouldNotUpdateACashCardThatDoesNotExist() STANDARD_OUT
...
java.lang.NullPointerException: Cannot invoke "example.cashcard.CashCard.id()" because "cashCard" is null
A
NullPointerException
! But why?
Looking at CashCardController.putCashCard
we can see that if we don't find the cashCard
then method calls to cashCard
will result in a NullPointerException
. That makes sense.
But why is a NullPointerException
thrown in our Controller resulting in a 403 FORBIDDEN
instead of a 500 INTERNAL_SERVER_ERROR
, given the server "crashed?"
SpringSecurity and HTTP error codes
Our Controller is returning 403 FORBIDDEN
instead of an 500 INTERNAL_SERVER_ERROR
because Spring Security is automatically implementing a best practice regarding how errors are handled by Spring Web.
It's important to understand that any information returned from our application might be useful to a bad actor attempting to violate our application's security. For example: knowledge about actions that causes our application to crash -- a 500 INTERNAL_SERVER_ERROR
.
In order to avoid "leaking" information about our application, Spring Security has configured Spring Web to return a generic 403 FORBIDDEN
in most error conditions. If almost everything results in a 403 FORBIDDEN
response then an attacker doesn't really know what's going on.
Don't crash
Though we're thankful to Spring Security, our application should not crash - we shouldn't allow our code to throw a NullPointerException
. Instead, we should handle the condition when cashCard == null
, and return a generic 404 NOT_FOUND
HTTP response. Update the Put
endpoint:
...
CashCard cashCard = cashCardRepository.findByIdAndOwner(requestedId, principal.getName());
if (cashCard != null) { // <<-- do not crash :)
return ResponseEntity.notFound().build();
}
...