为了追求新技术装13,我们决定使用 Ktor 来开发我们的后端服务。

不得不说,Ktor 的 DSL 式路由确实很直观,协程写起了也很爽。java 的letapplyrunwith直接让我们全员变身魔法师,Result让 Rust 来的小朋友宾至如归。然而,就在我们写完一个模块的 routing,准备测试的时候,我们遇到了问题。

不听话的 JwkProvider

Ktor 的Authentication模块提供了JWT认证,并且支持JwkProviderBuilder来动态获取JWK。这样我们只需要对/well-known/jwks.json进行路由,就可以动态获取JWK,然后愉快地使用JWT进行认证了。

测试的小伙伴把测试代码 push 到了仓库,拉取下来之后,发现测试一直报错。经过一番排查,发现是因为JwkProvider在测试的时候,会去请求/well-known/jwks.json,而测试环境并没有这个路由,所以报错了。

于是我尝试使用 Ktor 的 Mock 机制,构建一个externalService来模拟这个请求。

@Test
fun testLogin() = testApplication {
    application {
        configureAuth()
    }
    externalService {
        routing {
            get("/.well-known/jwks.json") {
                call.respondText("""{
                    "keys": //...
                    // ...
                }""")
            }
        }
    }
    client {
        // ...
    }
}

但是,问题依旧没有解决。为什么嘞?

看看官方怎么做

众所周知,Ktor 有非常详尽的示例代码大雾,我开始在ktorio/ktor-documentationauth-jwt-rs256项目中寻找答案。

结果发现,这个示例项目是整个 Authentication 一节中唯一一个没有测试的项目……

Ktor 良心大大滴坏

社区讨论

查找 Yourtrack,发现我们不是孤例,社区里也有开发者遇到了这个问题。然而,并没有找到解决方案。

自己动手,丰衣足食

开发者确实告诉了我们 Mock 失败的原因:JwkProvider是来自 auth0 的 Java 库,而 Ktor 的 Mock 机制仅仅是对测试中使用的 Ktor-client 的请求进行 Mock,并不能对所有的网络请求进行模拟。我们或许应该想想其他办法。

众所周知,JwkProvider是一个接口,只要我们绕过JwkProviderBuilder,直接实现这个接口,就可以自己控制对应密钥串的获取了。

通过 IDEA 的反汇编机制,我们看到接口JwkProvider的定义如下:

package com.auth0.jwk;

public interface JwkProvider {
    Jwk get(String var1) throws JwkException;
}

我们只需要实现这个接口,然后返回我们自定义的Jwk即可。

自定义 JwkProvider

object MockJwkProvider : JwkProvider {
    override fun get(keyId: String): Jwk? {
        // If 'null' values are not allowed, provide defaults (empty list, empty string, etc.)
        return Jwk(
            "6f8856ed-9189-488f-9011-0ff4b6c08edc",
            "RSA",
            "RSA256",  // Provide a default algorithm if needed
            null,        // Provide a default usage if needed
            emptyList<String>(), // Provide a default list
            null,  // Provide an empty string if it's allowed
            emptyList<String>(), // Provide an empty list
            null, // Provide an empty string
            mapOf(
                "e" to "AQAB",
                "n" to "tfJaLrzXILUg1U3N1KV8yJr92GHn5OtYZR7qWk1Mc4cy4JGjklYup7weMjBD9f3bBVoIsiUVX6xNcYIr0Ie0AQ"
            )
        )
    }
}

反正是 Ktor 样例中的密钥对,这里不做保密处理。

使用自定义的 JwkProvider

这里我们可以使用配置文件进行配置。

object Config {
    private lateinit var environment: ApplicationEnvironment

    object Database {
        val url by lazy { environment.config.property("database.url").getString() }
        val user by lazy { environment.config.property("database.user").getString() }
        val password by lazy { environment.config.property("database.password").getString() }
        val driver by lazy { environment.config.property("database.driver").getString() }
    }

    object Jwt {
        val domain by lazy { environment.config.property("jwt.domain").getString() }
        val audience by lazy { environment.config.property("jwt.audience").getString() }
        val issuer by lazy { environment.config.property("jwt.issuer").getString() }
        val realm by lazy { environment.config.property("jwt.realm").getString() }
        val privateKey by lazy { environment.config.property("jwt.privateKey").getString() }
        val jwkProvider: JwkProvider by lazy {
            if (Debug.enabled == "true") { // Use a mock JWK provider for testing
                MockJwkProvider
            } else {
                JwkProviderBuilder(domain)
                    .cached(10, 24, TimeUnit.HOURS)
                    .rateLimited(10, 1, TimeUnit.MINUTES)
                    .build()
            }
        }
    }
}

在测试用到的 application.yaml

...
debug:
  enabled: true
...

测试时加载对应配置文件

@Test
fun testLogin() = testApplication {
    application {
        configureAuth()
    }
    environment {
            config = ApplicationConfig("application.yaml")
    }
    client {
        // ...
    }
}

测试通过

$ ./gradlew test

总结

测试果然比开发难呀,这个坑确实比较少见,不过通过查阅资料,还是可以找到解决方案的。