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
HttpEntitythat theexchange()method needs:
HttpEntity<CashCard> request = new HttpEntity<>(existingCashCard);
- Then we call
exchange(), which sends aPUTrequest for the target ID of99and 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
@PutMappingsupports thePUTverb and supplies the targetrequestedId. -
The
@RequestBodycontains the updatedCashCarddata. -
We've added the
Principalas a method argument, provided automatically by Spring Security. Thanks once again, Spring Security! -
Than we scope our retrieval of the
CashCardto the submittedrequestedIdandPrincipal(provided by Spring Security) to ensure only the authenticated, authorizedownermay update thisCashCardcashCardRepository.findByIdAndOwner(requestedId, principal.getName()); -
Build a
CashCardwith updated values and save it. -
Return an HTTP
204 NO_CONTENTresponse 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();
}
...