It's easy to test forms and controllers in Play Framework, except for when you add real-life things like CSRF protection and authentication.
Other frameworks, like Django, automatically disable CSRF protection during tests, so that's not something I'm used to worrying about. They also had helpers defined to handle such common tasks as authentication. Not Play. Play doesn't have opinions here, for better or worse.
I prefer to have fewer environment-related switches in my production code, so I ended up just adding a method to my builder to handle CSRF additions. That ended up working out well because Play puts everything request-related in their Http.Context
object, which is not something I want to deal with on the regular. So I was able to use that stuff for other stuff. Into the builder it all goes!
Forgive the mess. It's kind of the code equivalent of the junk drawer in my kitchen.
import actions.AuthenticationAction;
import play.Application;
import play.api.libs.crypto.CSRFTokenSigner;
import play.api.mvc.RequestHeader;
import play.mvc.Call;
import play.mvc.Http;
import play.mvc.Http.RequestBuilder;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import static play.test.Helpers.fakeRequest;
public class MockRequestBuilder {
private static final String CSRF_SESSION = "csrfToken";
private static final String CSRF_HEADER = " Csrf-Token";
/** as found in play.filters.csrf.CSRF.Token.NameRequestTag() */
private static final String CSRF_NAME_REQUEST_TAG = "CSRF_TOKEN_NAME";
private static final String CSRF_REQUEST_TAG = "CSRF_TOKEN";
private RequestBuilder request;
private Application app;
private play.api.Application apiApp;
private String csrfToken;
public MockRequestBuilder(Application app, Call route) {
this.app = app;
request = fakeRequest(route);
}
public MockRequestBuilder(Application app, String method, String url) {
this.app = app;
request = fakeRequest(method, url);
}
public MockRequestBuilder(play.api.Application app, Call route) {
this.apiApp = app;
request = fakeRequest(route);
}
public RequestBuilder build() {
createHttpContext(request.build());
return request;
}
protected Http.Context createHttpContext(Http.Request request) {
long contextId = 1L;
RequestHeader requestHeader = request._underlyingHeader();
Map<String, String> flashData = Collections.emptyMap();
Map<String, Object> argData = Collections.emptyMap();
Http.Context httpContext = new Http.Context(
contextId, requestHeader, request, this.request.session(), flashData, argData);
Http.Context.current.set(httpContext);
return httpContext;
}
public MockRequestBuilder withCsrfToken() {
CSRFTokenSigner signer = app.injector().instanceOf(CSRFTokenSigner.class);
String token = signer.generateSignedToken();
this.csrfToken = token;
request.session(CSRF_SESSION, token);
request.tag(CSRF_NAME_REQUEST_TAG, CSRF_SESSION);
request.tag(CSRF_REQUEST_TAG, token);
String url = request.uri();
request.uri(url + (url.contains("?") ? "&" : "?") + "csrfToken=" + token);
return this;
}
public MockRequestBuilder withAuthenticatedUser() {
request.session(AuthenticationAction.SESSION_USERNAME_KEY, "testUser");
return this.withSessionExpiration(1);
}
public MockRequestBuilder withSessionExpiration(int numHours) {
Date expirationTime = org.apache.commons.lang3.time.DateUtils.addHours(new Date(), numHours);
request.session(AuthenticationAction.SESSION_EXPIRY_KEY, Long.toString(expirationTime.getTime()));
return this;
}
public MockRequestBuilder withForm(Map<String, Object> formFields) {
Map<String, String[]> arrayForm = convertToArrayForm(formFields);
request = request.bodyFormArrayValues(arrayForm);
return this;
}
/** Needed to conform with Play's API */
private Map<String, String[]> convertToArrayForm(Map<String, Object> formFields) {
Map<String, String[]> arrayFormValues = new HashMap<>();
for (Map.Entry<String, Object> formField : formFields.entrySet()) {
if (formField.getValue() instanceof String) {
String[] value = new String[]{(String) formField.getValue()};
arrayFormValues.put(formField.getKey(), value);
} else if (formField.getValue() instanceof String[]) {
arrayFormValues.put(formField.getKey(), (String[]) formField.getValue());
} else {
throw new RuntimeException("Cannot convert value to form encoding");
}
}
return arrayFormValues;
}
}
Now we can have tests like:
public class TestClass {
@Test
public void testCreateCustomer() {
MockRestClient client = app.injector().instanceOf(MockRestClient.class);
// I'm leaving out other dependency injection variables here, but you get the idea
CustomerController controller = new CustomerController(client);
Map<String, Object> formFields = new HashMap<>();
formFields.put("firstName", "Steve");
formFields.put("lastName", "McQueen");
formFields.put("emailAddress", "[email protected]");
new MockRequestBuilder(app, "POST", url)
.withAuthenticatedUser()
.withForm(formFields)
.withCsrfToken()
.build();
Result result = controller.create();
assertEquals(201, result.status());
}
Figuring out how to do the CSRF stuff took me a good few hours and a post on the Google group, so hopefully this finds someone before they spend all that time.
If you aren't familiar with Play requests and controllers...instead of passing the request directly to the controller method, you have to add them to the Http Context, which involves a bit of work. But it makes things easy-ish from the controller. For instance, you can just call request()
and get the request. Not that that's any easier than passing it as a param... Anyways, in my MockRequestBuilder
, the createHttpContext()
method takes care of all that, and that's called from build()
, so you don't have to worry about any of that. That is, unless you want to add stuff to the session or something. In which case, that's easy enough to change.
I hope this makes sense. I'd add comments, but don't feel like taking the time or dealing with russian bots, so feel free to write a blog article blasting me if it doesn't. Or send a friendly email to <redacted>
.