wu-lee
wu-lee

Reputation: 769

Given a Ratpack RequestFixture test, how can I have the fixture invoke Request#beforeSend?

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

Answers (1)

wu-lee
wu-lee

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

Related Questions