Reputation: 769
This question is in the context of a Ratpack RequestFixture
Spock test, for a Ratpack chain authenticating with RatpackPac4j#requireAuth
, and employing a workaround for the missing WWW-Authenticate
header (as described in the answer to this question)
The problem I have is, I find that #beforeSend
appears to be uncalled when the response is obtained from GroovyRequestFixture#handle
(a wrapper for RequestFixture#handle
). The work-around depends on this to work, so I can't test it. Is there a way to get #beforeSend
called on the response represented by the HandlingResult
returned?
For example, this test case fails with the assertion that the WWW-Authenticate
header is present, even though the code this is adapted from inserts the header correctly when called in the actual application. The chain under test is testChain
, skip to the end for the failing assertion:
package whatever
import com.google.inject.Module
import groovy.transform.Canonical
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import org.pac4j.core.profile.jwt.JwtClaims
import org.pac4j.http.client.direct.HeaderClient
import org.pac4j.jwt.config.encryption.EncryptionConfiguration
import org.pac4j.jwt.config.encryption.SecretEncryptionConfiguration
import org.pac4j.jwt.config.signature.SecretSignatureConfiguration
import org.pac4j.jwt.config.signature.SignatureConfiguration
import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator
import org.pac4j.jwt.profile.JwtGenerator
import org.pac4j.jwt.profile.JwtProfile
import ratpack.groovy.handling.GroovyChainAction
import ratpack.groovy.test.handling.GroovyRequestFixture
import ratpack.guice.Guice
import ratpack.http.Response
import ratpack.http.Status
import ratpack.jackson.Jackson
import ratpack.pac4j.RatpackPac4j
import ratpack.registry.Registry
import ratpack.session.SessionModule
import ratpack.test.handling.HandlerExceptionNotThrownException
import ratpack.test.handling.HandlingResult
import spock.lang.Specification
@CompileStatic
class AuthenticatorTest extends Specification {
static byte[] salt = new byte[32] // dummy salt
static SignatureConfiguration signatureConfiguration = new SecretSignatureConfiguration(salt)
static EncryptionConfiguration encryptionConfiguration = new SecretEncryptionConfiguration(salt)
static JwtAuthenticator authenticator = new JwtAuthenticator(signatureConfiguration, encryptionConfiguration)
static JwtGenerator generator = new JwtGenerator(signatureConfiguration, encryptionConfiguration)
static HeaderClient headerClient = new HeaderClient("Authorization", "bearer ", authenticator)
/** A stripped down user class */
@Canonical
static class User {
final String id
}
/** A stripped down user registry class */
@Canonical
static class UserRegistry {
private final Map<String, String> users = [
'joebloggs': 'sekret'
]
User authenticate(String id, String password) {
if (password != null && users[id] == password)
return new User(id)
return null
}
}
/** Generates a JWT token for a given user
*
* @param userId - the name of the user
* @return A JWT token encoded as a string
*/
static String generateToken(String userId) {
JwtProfile profile = new JwtProfile()
profile.id = userId
profile.addAttribute(JwtClaims.ISSUED_AT, new Date())
String token = generator.generate(profile)
token
}
static void trapExceptions(HandlingResult result) {
try {
Throwable t = result.exception(Throwable)
throw t
}
catch (HandlerExceptionNotThrownException ignored) {
}
}
/** Composes a new registry binding the module class passed
* as per SO question https://stackoverflow.com/questions/50814817/how-do-i-mock-a-session-in-ratpack-with-requestfixture
*/
static Registry addModule(Registry registry, Class<? extends Module> module) {
Guice.registry { it.module(module) }.apply(registry)
}
GroovyChainAction testChain = new GroovyChainAction() {
@Override
@CompileDynamic
void execute() throws Exception {
register addModule(registry, SessionModule)
all RatpackPac4j.authenticator(headerClient)
all {
/*
* This is a workaround for an issue in RatpackPac4j v2.0.0, which doesn't
* add the WWW-Authenticate header by itself.
*
* See https://github.com/pac4j/ratpack-pac4j/issues/3
*
* This handler needs to be ahead of any potential causes of 401 statuses
*/
response.beforeSend { Response response ->
if (response.status.code == 401) {
response.headers.set('WWW-Authenticate', 'bearer realm="authenticated api"')
}
}
next()
}
post('login') { UserRegistry users ->
parse(Jackson.fromJson(Map)).then { Map data ->
// Validate the credentials
String id = data.user
String password = data.password
User user = users.authenticate(id, password)
if (user == null) {
clientError(401) // Bad authentication credentials
} else {
response.contentType('text/plain')
// Authenticates ok. Issue a JWT token to the client which embeds (signed, encrypted)
// certain standardised metadata of our choice that the JWT validation will use.
String token = generateToken(user.id)
render token
}
}
}
get('unprotected') {
render "hello"
}
// All subsequent paths require authentication
all RatpackPac4j.requireAuth(HeaderClient)
get('protected') {
render "hello"
}
notFound()
}
}
@CompileDynamic
def "should be denied protected path, unauthorised..."() {
given:
def result = GroovyRequestFixture.handle(testChain) {
uri 'protected'
method 'GET'
}
expect:
result.status == Status.of(401) // Unauthorized
// THIS FAILS BECAUSE Response#beforeSend WASN'T INVOKED BY GroovyRequestFixture
result.headers['WWW-Authenticate'] == 'bearer realm="authenticated api"'
// If the server threw, rethrow that
trapExceptions(result)
}
}
Upvotes: 1
Views: 512
Reputation: 769
Best answer so far... or more strictly, a workaround to sidestep the limitations of RequestFixture
, is: don't use RequestFixture
. Use GroovyEmbeddedApp
(Credit to Dan Hyun on the Ratpack slack channel)
RequestFixture is only meant to check handler behavior, it doesn't do a lot of things - it won't serialize responses.
EmbeddedApp
is probably the way to go for most testing. You'd care more about overall interaction rather than how an individual handler does a thing, unless it was a highly reused component or is middleware that is used by other apps
An modified version of the example above follows, I've marked the modified sections in the comments:
package whatever
import com.google.inject.Module
import groovy.transform.Canonical
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import org.pac4j.core.profile.jwt.JwtClaims
import org.pac4j.http.client.direct.HeaderClient
import org.pac4j.jwt.config.encryption.EncryptionConfiguration
import org.pac4j.jwt.config.encryption.SecretEncryptionConfiguration
import org.pac4j.jwt.config.signature.SecretSignatureConfiguration
import org.pac4j.jwt.config.signature.SignatureConfiguration
import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator
import org.pac4j.jwt.profile.JwtGenerator
import org.pac4j.jwt.profile.JwtProfile
import ratpack.groovy.test.embed.GroovyEmbeddedApp
import ratpack.guice.Guice
import ratpack.http.Response
import ratpack.http.Status
import ratpack.http.client.ReceivedResponse
import ratpack.jackson.Jackson
import ratpack.pac4j.RatpackPac4j
import ratpack.registry.Registry
import ratpack.session.SessionModule
import ratpack.test.handling.HandlerExceptionNotThrownException
import ratpack.test.handling.HandlingResult
import ratpack.test.http.TestHttpClient
import spock.lang.Specification
@CompileStatic
class TempTest extends Specification {
static byte[] salt = new byte[32] // dummy salt
static SignatureConfiguration signatureConfiguration = new SecretSignatureConfiguration(salt)
static EncryptionConfiguration encryptionConfiguration = new SecretEncryptionConfiguration(salt)
static JwtAuthenticator authenticator = new JwtAuthenticator(signatureConfiguration, encryptionConfiguration)
static JwtGenerator generator = new JwtGenerator(signatureConfiguration, encryptionConfiguration)
static HeaderClient headerClient = new HeaderClient("Authorization", "bearer ", authenticator)
/** A stripped down user class */
@Canonical
static class User {
final String id
}
/** A stripped down user registry class */
@Canonical
static class UserRegistry {
private final Map<String, String> users = [
'joebloggs': 'sekret'
]
User authenticate(String id, String password) {
if (password != null && users[id] == password)
return new User(id)
return null
}
}
/** Generates a JWT token for a given user
*
* @param userId - the name of the user
* @return A JWT token encoded as a string
*/
static String generateToken(String userId) {
JwtProfile profile = new JwtProfile()
profile.id = userId
profile.addAttribute(JwtClaims.ISSUED_AT, new Date())
String token = generator.generate(profile)
token
}
static void trapExceptions(HandlingResult result) {
try {
Throwable t = result.exception(Throwable)
throw t
}
catch (HandlerExceptionNotThrownException ignored) {
}
}
/** Composes a new registry binding the module class passed
* as per SO question https://stackoverflow.com/questions/50814817/how-do-i-mock-a-session-in-ratpack-with-requestfixture
*/
static Registry addModule(Registry registry, Class<? extends Module> module) {
Guice.registry { it.module(module) }.apply(registry)
}
/*** USE GroovyEmbeddedApp HERE INSTEAD OF GroovyResponseFixture ***/
GroovyEmbeddedApp testApp = GroovyEmbeddedApp.ratpack {
bindings {
module SessionModule
}
handlers {
all RatpackPac4j.authenticator(headerClient)
all {
/*
* This is a workaround for an issue in RatpackPac4j v2.0.0, which doesn't
* add the WWW-Authenticate header by itself.
*
* See https://github.com/pac4j/ratpack-pac4j/issues/3
*
* This handler needs to be ahead of any potential causes of 401 statuses
*/
response.beforeSend { Response response ->
if (response.status.code == 401) {
response.headers.set('WWW-Authenticate', 'bearer realm="authenticated api"')
}
}
next()
}
post('login') { UserRegistry users ->
parse(Jackson.fromJson(Map)).then { Map data ->
// Validate the credentials
String id = data.user
String password = data.password
User user = users.authenticate(id, password)
if (user == null) {
clientError(401) // Bad authentication credentials
} else {
response.contentType('text/plain')
// Authenticates ok. Issue a JWT token to the client which embeds (signed, encrypted)
// certain standardised metadata of our choice that the JWT validation will use.
String token = generateToken(user.id)
render token
}
}
}
get('unprotected') {
render "hello"
}
// All subsequent paths require authentication
all RatpackPac4j.requireAuth(HeaderClient)
get('protected') {
render "hello"
}
notFound()
}
}
/*** THIS NOW ALTERED TO USE testApp ***/
@CompileDynamic
def "should be denied protected path, unauthorised..."() {
given:
TestHttpClient client = testApp.httpClient
ReceivedResponse response = client.get('protected')
expect:
response.status == Status.of(401) // Unauthorized
response.headers['WWW-Authenticate'] == 'bearer realm="authenticated api"'
}
}
Upvotes: 1