1 Put
djmil edited this page 2023-07-25 12:14:11 +02:00

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 the exchange() method needs:
HttpEntity<CashCard> request = new HttpEntity<>(existingCashCard);
  • Then we call exchange(), which sends a PUT request for the target ID of 99 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 the PUT verb and supplies the target requestedId.

  • The @RequestBody contains the updated CashCard 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 submitted requestedId and Principal (provided by Spring Security) to ensure only the authenticated, authorized owner may update this CashCard

    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();
}
...