Skip to main content

ยท 5 min read

So, as we know, testing is important. Sure, manually testing your application before you ship it into production is good. But what about automated tests. What do we do when you need something which is not available from your framework? We built it ourselves.

The Problem

So I use ktor and JUnit Jupiter in my application. I wrote some code which handles authorization based on the permissions claim in JWTs. It's based on the regular JWT authentication support found in ktor. And because when authentication and authorization would not work correctly, I would have a very big problem, I decided to write automated tests. My framework, ktor, comes with great support for testing out of the box. But not for easy JWT authentication in tests. So I thought about it and came up with the idea to just create a JWT authentication configuration in my test and give it a verifier with a fixed secret. When I needed a valid token, I could just create one with the fixed secret.

First Solution

So, as I said, my first solution then looked like this.

@Test
fun `Request with a valid token succeeds`() = testApplication {
val algorithm = Algorithm.HMAC256("test secret")
val verifier = JWT.require(algorithm).build()
install(Authentication) {
jwt {
validate { JWTPrincipal(it.payload) }
verifier(verifier)
}
}
routing {
authenticate {
handle {
call.respond(HttpStatusCode.OK)
}
}
}
val status = client.get("/") {
bearerAuth(JWT.create().sign(algorithm))
}.status
assertThat(status).isEqualTo(HttpStatusCode.OK)
}

I created a new verifier, authentication configuration and a new token in the request. So that works fine, but could be improved. Imagine we're writing some more tests, and we would have to write the same JWT configuration code over and over again. We could just move our verifier and token into the class, but what if we want to test JWTs in multiple classes?

JUnit Extensions to the rescue

So JUnit 5 provides an API to extend it with new functionality. The description of JUnit Extensions in the JUnit 5 user guide is excellent. What we plan to do is that we can get the verifier and a valid token via function parameters. So that seems to be ell. There is even an example similar to what we want to do on GitHub.

So the first thing we do is creating a new class which extends ParameterResolver.

class JWTExtension : ParameterResolver

Now we have to implement 2 functions. supportsParameter and resolveParameter.

So let's start with supportsParameter. This function gets called by JUnit for every parameter we have in our tests. And we just tell JUnit if we can provide a value for that parameter. So we want to provide values for JWTVerifier and String parameters.

override fun supportsParameter(
parameterContext: ParameterContext,
extensionContext: ExtensionContext,
): Boolean {
return parameterContext.parameter.type == JWTVerifier::class.java ||
parameterContext.parameter.type == String::class.java
}

But how can we differentiate between other String parameters and the ones where we want to provide a token? We can create an annotation for that.

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class TestJWT

So let's add that to our supportsParameter function.

override fun supportsParameter(
parameterContext: ParameterContext,
extensionContext: ExtensionContext,
): Boolean {
return parameterContext.parameter.type == JWTVerifier::class.java ||
parameterContext.parameter.type == String::class.java &&
parameterContext.isAnnotated(TestJWT::class.java)
}

Now let's continue and implement resolveParameter. We also need to add a field to the class.

class JWTExtension : ParameterResolver {
private val algorithm = Algorithm.HMAC256("test secret")
// ...
override fun resolveParameter(
parameterContext: ParameterContext,
extensionContext: ExtensionContext,
): Any {
return when (parameterContext.parameter.type.kotlin) {
JWTVerifier::class -> {
JWT.require(algorithm).build()
}
String::class -> {
JWT.create().sign(algorithm)
}
else -> error("Invalid type.")
}
}
}

So let's adjust our test.

@Test
fun `Request with a valid token succeeds`(verifier: JWTVerifier, @TestJWT token: String) =
testApplication {
install(Authentication) {
jwt {
validate { JWTPrincipal(it.payload) }
verifier(verifier)
}
}
routing {
authenticate {
handle {
call.respond(HttpStatusCode.OK)
}
}
}
val status = client.get("/") {
bearerAuth(token)
}.status
assertThat(status).isEqualTo(HttpStatusCode.OK)
}

Now let's enable JUnit's automatic extension loading mechanism in our build.gradle.kts.

tasks {
withType<Test> {
useJUnitPlatform()
systemProperty("junit.jupiter.extensions.autodetection.enabled", true)
}
}

Don't repeat yourself

So, but what is when we are writing multiple tests. We would still have to repeat some code. And that's bad. Let's fix that. Let's write a little utility function where we provide sense defaults for our tests.

internal fun testApplicationWithJWT(
verifier: JWTVerifier,
token: String?,
routing: Routing.() -> Unit = {
authenticate {
handle {
call.respond(HttpStatusCode.OK)
}
}
},
expectedCode: HttpStatusCode = HttpStatusCode.OK,
request: HttpRequestBuilder.() -> Unit = {
token?.let(::bearerAuth)
},
block: suspend ApplicationTestBuilder.(HttpResponse) -> Unit = {},
) = testApplication {
install(Authentication) {
jwt {
validate { JWTPrincipal(it.payload) }
verifier(verifier)
}
}
routing(routing)
val response = client.get("/", request)
assertThat(response.status).isEqualTo(expectedCode)
block(response)
}

So let's adjust our test again.

@Test
fun `Request with valid token succeeds`(
verifier: JWTVerifier,
@TestJWT("test") token: String,
) = testApplicationWithJWT(verifier = verifier, token = token)

And let's write another one.

@Test
fun `Request without token fails with Unauthorized`(verifier: JWTVerifier) =
testApplicationWithJWT(
verifier = verifier,
token = null,
expectedCode = HttpStatusCode.Unauthorized
)

So of course in a real world application, we would write some more tests, but I'll leave that to you :)

View Full Example

Used Libraries:

ยท 6 min read

Introductionโ€‹

Both then and now, many developers in the Bukkit ecosystem use Maven. There are also some developers which are not using a real build tool at all. Please don't use the Eclipse Build Path or IntelliJ Artifacts for dependencies.

So whats wrong with Maven you may now ask?

Don't get me wrong, Maven is a great tool. But for the times we live in, it's not up to date. I don't think it's good to have to write 75 lines of xml for a simple plugin. If you want to do more complex tasks, it gets damn complicated. Gradle is not only faster than maven. Since the paperweight userdev plugin is available, there is a nice way to use NMS code with Mojang Mappings without having to deal with obfuscated code. Even if you don't want to use NMS Code, Gradle has it's advantages.

For Example, you don't write your build logic in XML, but in one (or more) Groovy or Kotlin scripts. With the Kotlin variant, your IDE is even able to provide type checking and auto complete for it. Here is an example of the maven configuration mentioned earlier. Just 25 lines of kotlin code.

IDE Supportโ€‹

IntelliJ IDEAโ€‹

IntelliJ IDEA has great support for Gradle. I'll use IntelliJ IDEA Ultimate in this tutorial, but the community edition has all features we need for this tutorial.

Eclipseโ€‹

Eclipse supports some Gradle features via Eclipse Buildship, but it's not really great. Also eclipse provides no support for the Gradle Kotlin DSL, which we are going to use. I would recommend you to use IntelliJ IDEA instead. There is a migration guide for eclipse users available here.

Getting Startedโ€‹

To create our project, start up IntelliJ IDEA and click on Create Project in the main menu or select File -> New Project if you already have a project opened. Select Gradle in the sidebar on the left. Select a JDK with version 17 in the selection for Project SDK. You can download a JDK via the selection box if you don't have one installed. I recommend to use Eclipse Temurin if you don't have one. Also, make sure to check the Kotlin DSL build script checkbox. Only select Java under Additional Libraries and Frameworks.

Project Creation Screen

Now, click Next.

In the next screen, you can choose the project name and the path on your system. Click on Artifact Coordinates to set your projects Maven Coordinates. (yes gradle uses maven's dependency system).

Project Name and Path Screen

Click on Finish to create your project and wait until IntelliJ has loaded and indexed your project.

Updating Gradleโ€‹

Because IntelliJ uses the Gradle Tooling API to manage Gradle Projects, the version which IntelliJ uses is often a bit outdated. We'll now upgrade our project to use the latest and greatest Gradle version.

You may have seen that there are several files in your project folder like gradlew, gradlew.bat and a folder structure with a gradle-wrapper.jar and gradle-wrapper.properties. This is because gradle with its wrapper makes sure that the gradle version can be set for each project. Because of that you don't need to install gradle on your system. Everything is handled by the Gradle Wrapper.

Gradle Wrapper Project Structure

As I write this, the latest Gradle version is 7.3.3, but please check here if a newer one is available. To upgrade your gradle version, run the following command in your project. We also want to use the all distribution type which includes sources and docs, because otherwise IntelliJ would download them as well.

./gradlew wrapper --gradle-version 7.3.3 --distribution-type all

Adding Paper to your projectโ€‹

So now we are using the latest version of gradle. Let's remove some stuff from our build script. IntelliJ adds some defaults which we don't need.

Open the build.gradle.kts and adjust it like this.

build.gradle.kts
plugins {
java
}

// Use your group id here of course
group = "dev.nycode"
version = "1.0-SNAPSHOT"

repositories {
mavenCentral()
}

dependencies {
}

Now we have an empty Gradle project. Let's add Paper to the build.

build.gradle.kts
plugins {
java
id("io.papermc.paperweight.userdev") version "1.3.3"
}

Let's also set our Java Language Version to 17.

build.gradle.kts
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}

We also have to add the PaperMC Maven repository to our gradle plugin sources to be able to resolve the plugin.

Adjust your settings.gradle.kts as the following:

settings.gradle.kts
// Use your project name here of course
rootProject.name = "paper-blog-post"

pluginManagement {
repositories {
gradlePluginPortal()
maven("https://papermc.io/repo/repository/maven-public/")
}
}

Now we can add a dependency on the paper dev bundle.

build.gradle.kts
dependencies {
paperDevBundle("1.18.1-R0.1-SNAPSHOT")
}

Let's make sure the reobfJar task is executed when we build our plugin.

build.gradle.kts
tasks {
assemble {
dependsOn(reobfJar)
}
}

Our build.gradle.kts now should like the following:

build.gradle.kts
plugins {
java
id("io.papermc.paperweight.userdev") version "1.3.3"
}

group = "dev.nycode"
version = "1.0-SNAPSHOT"

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}

repositories {
mavenCentral()
}

dependencies {
paperDevBundle("1.18.1-R0.1-SNAPSHOT")
}

tasks {
assemble {
dependsOn(reobfJar)
}
}

Now sync your changes in IntelliJ and wait until IntelliJ re-synced the project. All errors should be gone after that. Now you can freely work with mojang mapped NMS and deobfuscated names.

You can try out the example shown in Paper's paperweight test repository.

To build your plugin just execute Gradle's build task.

./gradlew build

Your reobfuscated plugin jar will be in build/libs/<project-name>-<version>.jar There will be also a jar with a -dev suffix. This file won't run on normal servers, because it's using the deobfuscated mojang mappings.

write a comment