Prechádzať zdrojové kódy

Mock bot and session test

ryoii 4 rokov pred
rodič
commit
08f82ae2b3

+ 5 - 1
mirai-api-http/build.gradle.kts

@@ -13,7 +13,7 @@ kotlin {
     sourceSets["test"].apply {
         dependencies {
             api("org.slf4j:slf4j-simple:1.7.26")
-            api(kotlin("test-junit"))
+            api(kotlin("test-junit5"))
         }
     }
 
@@ -72,6 +72,10 @@ tasks.create("buildCiJar", Jar::class) {
     }
 }
 
+tasks.test {
+    useJUnitPlatform()
+}
+
 mavenCentralPublish {
     githubProject("project-mirai", "mirai-api-http")
     licenseFromGitHubProject("licenseAgplv3", "master")

+ 16 - 0
mirai-api-http/src/test/kotlin/annotation/annotations.kt

@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package annotation
+
+/**
+ * annotation alias
+ */
+
+typealias ExtendWith = org.junit.jupiter.api.extension.ExtendWith

+ 22 - 0
mirai-api-http/src/test/kotlin/extenssion/SetupBotMock.kt

@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package extenssion
+
+import kotlinx.coroutines.runBlocking
+import mock.BotMockStub
+import org.junit.jupiter.api.extension.BeforeAllCallback
+import org.junit.jupiter.api.extension.ExtensionContext
+
+class SetupBotMock: BeforeAllCallback {
+
+    override fun beforeAll(context: ExtensionContext?) {
+        runBlocking { BotMockStub().login() }
+    }
+}

+ 78 - 0
mirai-api-http/src/test/kotlin/mock/BotMockStub.kt

@@ -0,0 +1,78 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package mock
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.event.EventChannel
+import net.mamoe.mirai.event.GlobalEventChannel
+import net.mamoe.mirai.event.events.BotEvent
+import net.mamoe.mirai.utils.BotConfiguration
+import net.mamoe.mirai.utils.MiraiInternalApi
+import net.mamoe.mirai.utils.MiraiLogger
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.reflect.KProperty
+import kotlin.reflect.full.companionObject
+
+class BotMockStub : Bot {
+
+    companion object {
+        const val ID = 0L
+        const val NICK_NAME = "Mock Bot"
+    }
+
+    override val asFriend: Friend by lazy { FriendMockStub(this) }
+    override val asStranger: Stranger by lazy { StrangerMockStub(this) }
+
+    override val configuration: BotConfiguration
+        get() = TODO("Not yet implemented")
+
+    override val coroutineContext: CoroutineContext = EmptyCoroutineContext
+
+    override val eventChannel: EventChannel<BotEvent> =
+        GlobalEventChannel.filterIsInstance<BotEvent>().filter { it.bot === this }
+
+    @OptIn(MiraiInternalApi::class)
+    override val friends: ContactList<Friend> by lazy {
+        ContactList(listOf(asFriend))
+    }
+
+    @OptIn(MiraiInternalApi::class)
+    override val groups: ContactList<Group> by lazy {
+        ContactList(listOf(GroupMockStub(this)))
+    }
+
+    override val id: Long = ID
+    override val isOnline: Boolean = true
+    override val logger: MiraiLogger = MiraiLogger.create("Mock bot")
+    override val nick: String = NICK_NAME
+
+    @OptIn(MiraiInternalApi::class)
+    override val otherClients: ContactList<OtherClient> = ContactList(listOf())
+
+    @OptIn(MiraiInternalApi::class)
+    override val strangers: ContactList<Stranger> by lazy {
+        ContactList(listOf(asStranger))
+    }
+
+    override fun close(cause: Throwable?) {}
+
+    @Suppress("UNCHECKED_CAST")
+    override suspend fun login() {
+        Bot::class.companionObject?.members?.first { it.name == "_instances" }?.let {
+            it as KProperty<ConcurrentHashMap<Long, Bot>>
+            val map = it.call(Bot::class.companionObject?.objectInstance)
+            map[ID] = this
+        }
+    }
+
+}

+ 46 - 0
mirai-api-http/src/test/kotlin/mock/FriendMockStub.kt

@@ -0,0 +1,46 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package mock
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Friend
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.Image
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.OfflineAudio
+import net.mamoe.mirai.utils.ExternalResource
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+class FriendMockStub(
+    override val bot: Bot
+) : Friend {
+
+    override val coroutineContext: CoroutineContext = EmptyCoroutineContext
+    override val id: Long = bot.id
+    override val nick: String = bot.nick
+    override val remark: String = bot.nick
+
+    override suspend fun delete() {
+    }
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<Friend> {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun uploadImage(resource: ExternalResource): Image {
+        TODO("Not yet implemented")
+    }
+
+}

+ 88 - 0
mirai-api-http/src/test/kotlin/mock/GroupMockStub.kt

@@ -0,0 +1,88 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package mock
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.ContactList
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.GroupSettings
+import net.mamoe.mirai.contact.NormalMember
+import net.mamoe.mirai.contact.announcement.Announcements
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.MiraiInternalApi
+import net.mamoe.mirai.utils.RemoteFile
+import kotlin.coroutines.CoroutineContext
+
+class GroupMockStub(
+    override val bot: Bot
+) : Group {
+
+    companion object {
+        const val ID = 1L
+        const val NAME = "Mock Group"
+    }
+
+    override val id: Long = ID
+    override var name = NAME
+
+    override val owner: NormalMember = MemberMockStub(this)
+
+    @OptIn(MiraiInternalApi::class)
+    override val members: ContactList<NormalMember> = ContactList(listOf(owner))
+
+    override fun contains(id: Long): Boolean {
+        return id == MemberMockStub.ID
+    }
+
+    override fun get(id: Long): NormalMember? {
+        return if (id == MemberMockStub.ID) {
+            owner
+        } else {
+            null
+        }
+    }
+
+    override suspend fun quit(): Boolean {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<Group> {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun setEssenceMessage(source: MessageSource): Boolean {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun uploadImage(resource: ExternalResource): Image {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun uploadVoice(resource: ExternalResource): Voice {
+        TODO("Not yet implemented")
+    }
+
+    override val announcements: Announcements
+        get() = TODO("Not yet implemented")
+    override val botAsMember: NormalMember
+        get() = TODO("Not yet implemented")
+    override val coroutineContext: CoroutineContext
+        get() = TODO("Not yet implemented")
+    override val filesRoot: RemoteFile
+        get() = TODO("Not yet implemented")
+    override val settings: GroupSettings
+        get() = TODO("Not yet implemented")
+}

+ 72 - 0
mirai-api-http/src/test/kotlin/mock/MemberMockStub.kt

@@ -0,0 +1,72 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package mock
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.contact.NormalMember
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.Image
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.utils.ExternalResource
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+class MemberMockStub(
+    override val group: Group
+): NormalMember {
+
+    companion object {
+        const val ID = 2L
+        const val TIMESTAMP = 9
+        const val NAME = "Mock Member"
+    }
+
+    override val coroutineContext: CoroutineContext = EmptyCoroutineContext
+
+    override val bot: Bot = group.bot
+    override val id: Long = ID
+
+    override val joinTimestamp: Int = TIMESTAMP
+    override val lastSpeakTimestamp: Int = TIMESTAMP
+    private var muteRemain = 0
+    override val muteTimeRemaining: Int get() = muteRemain
+    override var nameCard: String = NAME
+    override val nick: String = NAME
+    override val permission: MemberPermission = MemberPermission.OWNER
+    override val remark: String = NAME
+    override var specialTitle: String = NAME
+
+    override suspend fun kick(message: String) {
+    }
+
+    override suspend fun kick(message: String, block: Boolean) {
+    }
+
+    override suspend fun modifyAdmin(operation: Boolean) {
+    }
+
+    override suspend fun mute(durationSeconds: Int) {
+        muteRemain = durationSeconds
+    }
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<NormalMember> {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun unmute() {
+        muteRemain = 0
+    }
+
+    override suspend fun uploadImage(resource: ExternalResource): Image {
+        TODO("Not yet implemented")
+    }
+}

+ 21 - 0
mirai-api-http/src/test/kotlin/mock/SessionMockUtil.kt

@@ -0,0 +1,21 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package mock
+
+import net.mamoe.mirai.api.http.adapter.internal.dto.AuthedDTO
+import kotlin.reflect.jvm.javaField
+
+internal fun <T: AuthedDTO> T.withSession(sessionKey: String): T {
+    this::sessionKey.javaField?.apply {
+        isAccessible = true
+        set(this@withSession, sessionKey)
+    }
+    return this
+}

+ 40 - 0
mirai-api-http/src/test/kotlin/mock/StrangerMockStub.kt

@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package mock
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Stranger
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.Image
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.utils.ExternalResource
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+class StrangerMockStub(
+    override val bot: Bot,
+) : Stranger {
+
+    override val coroutineContext: CoroutineContext = EmptyCoroutineContext
+    override val id: Long = bot.id
+    override val nick: String = bot.nick
+    override val remark: String = bot.nick
+
+    override suspend fun delete() {
+    }
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<Stranger> {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun uploadImage(resource: ExternalResource): Image {
+        TODO("Not yet implemented")
+    }
+}

+ 10 - 3
mirai-api-http/src/test/kotlin/net/mamoe/mirai/api/http/context/cache/TestSourceCache.kt

@@ -1,9 +1,16 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
 package net.mamoe.mirai.api.http.context.cache
 
-import net.mamoe.mirai.message.data.OnlineMessageSource
-import org.junit.Test
-import java.util.concurrent.CountDownLatch
 import kotlin.concurrent.thread
+import kotlin.test.Test
 import kotlin.test.assertEquals
 
 class TestSourceCache {

+ 135 - 0
mirai-api-http/src/test/kotlin/net/mamoe/mirai/api/http/request/env/enviroment.kt

@@ -0,0 +1,135 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package net.mamoe.mirai.api.http.request.env
+
+import io.ktor.client.*
+import io.ktor.client.features.websocket.*
+import io.ktor.client.request.*
+import io.ktor.http.*
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.api.http.MahPluginImpl
+import net.mamoe.mirai.api.http.adapter.MahAdapter
+import net.mamoe.mirai.api.http.adapter.MahAdapterFactory
+import net.mamoe.mirai.api.http.adapter.internal.dto.DTO
+import net.mamoe.mirai.api.http.adapter.internal.serializer.jsonElementParseOrNull
+import net.mamoe.mirai.api.http.adapter.internal.serializer.jsonParseOrNull
+import net.mamoe.mirai.api.http.adapter.ws.dto.WsOutgoing
+import net.mamoe.mirai.api.http.context.MahContext
+import net.mamoe.mirai.api.http.context.session.manager.DefaultSessionManager
+import java.net.InetSocketAddress
+import java.net.Socket
+import kotlin.reflect.KProperty
+import kotlin.reflect.KProperty1
+import kotlin.reflect.jvm.isAccessible
+import kotlin.reflect.jvm.javaField
+
+internal fun startAdapter(
+    vararg adapters: String,
+    verifyKey: String = "",
+    enableVerify: Boolean = true,
+    singleMode: Boolean = false,
+    debug: Boolean = false,
+    operation: suspend AdapterOperation.() -> Unit,
+) {
+    val port = getRandomPort()
+
+    // launch adapter
+    MahPluginImpl.start {
+        sessionManager = DefaultSessionManager(verifyKey, this)
+        this.enableVerify = enableVerify
+        this.singleMode = singleMode
+        this.debug = debug
+
+        // clean adapter list in context
+        @Suppress("UNCHECKED_CAST")
+        MahContext::class.members.first { it.name == "adapters" }.let {
+            it as KProperty<MutableList<MahAdapter>>
+            it.call(this).clear()
+        }
+
+        @Suppress("UNCHECKED_CAST")
+        for (adapter in adapters) {
+            val mahAdapter = MahAdapterFactory.build(adapter) ?: continue
+
+            val setting = mahAdapter::class.members.firstOrNull { it.name == "setting" }?.let {
+                it as KProperty<Any>
+                it.isAccessible = true
+                it.getter.call(mahAdapter)
+            } ?: continue
+
+            setting::class.members.firstOrNull { it.name == "port" }?.let {
+                it as KProperty1<*, Int>
+                it.isAccessible = true
+                it.javaField?.set(setting, port)
+            }
+
+            this.plusAssign(mahAdapter)
+        }
+    }
+
+    // invoke operations
+    runBlocking {
+        val operations = AdapterOperation(port)
+        operations.operation()
+    }
+
+    MahPluginImpl.stop()
+}
+
+internal class AdapterOperation(val port: Int) {
+
+
+    private val client by lazy { HttpClient() }
+    private val wsClient by lazy { HttpClient() { install(WebSockets) }}
+
+    suspend inline fun <reified T : DTO> get(path: String, query: Map<String, String> = emptyMap()): T {
+        val content = client.get<String>(path) {
+            port = [email protected]
+            url {
+                query.forEach { (k, v) -> parameters.append(k, v) }
+            }
+        }
+        return content.jsonParseOrNull()!!
+    }
+
+    suspend inline fun <reified T : DTO> post(path: String, data: String): T {
+        val context = client.post<String>(path) {
+            port = [email protected]
+            body = data
+        }
+        return context.jsonParseOrNull()!!
+    }
+
+    inline fun <reified T : DTO>wsConnect(query: Map<String, String>): T? {
+        var ret: WsOutgoing? = null
+        runBlocking {
+            wsClient.ws({
+                url("ws", "localhost", [email protected], "all") {
+                    query.forEach { (k, v) -> parameters[k] = v }
+                }
+            }) {
+                val frame = incoming.receive()
+                val content = String(frame.data)
+                // println(content)
+                ret = content.jsonParseOrNull()
+                return@ws
+            }
+        }
+        return ret?.data?.jsonElementParseOrNull()
+    }
+}
+
+internal fun getRandomPort(): Int {
+    val socket = Socket()
+    socket.bind(InetSocketAddress(0))
+    val port = socket.localPort
+    socket.close()
+    return port
+}

+ 171 - 0
mirai-api-http/src/test/kotlin/net/mamoe/mirai/api/http/request/http/HttpAuthTest.kt

@@ -0,0 +1,171 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package net.mamoe.mirai.api.http.request.http
+
+import annotation.ExtendWith
+import extenssion.SetupBotMock
+import kotlinx.coroutines.runBlocking
+import mock.BotMockStub
+import mock.withSession
+import net.mamoe.mirai.api.http.adapter.common.StateCode
+import net.mamoe.mirai.api.http.adapter.internal.consts.Paths
+import net.mamoe.mirai.api.http.adapter.internal.dto.*
+import net.mamoe.mirai.api.http.adapter.internal.serializer.jsonElementParseOrNull
+import net.mamoe.mirai.api.http.adapter.internal.serializer.toJson
+import net.mamoe.mirai.api.http.context.MahContext
+import net.mamoe.mirai.api.http.request.env.AdapterOperation
+import net.mamoe.mirai.api.http.request.env.startAdapter
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+/**
+ * 测试 Session 在不同情况下的正确性
+ */
+@ExtendWith(SetupBotMock::class)
+class HttpAuthTest {
+
+    private val verifyKey = "session test"
+    private val verifyPath = Paths.httpPath("verify")
+    private val bindPath = Paths.httpPath("bind")
+    private val action = Paths.sessionInfo
+
+    /**
+     * 测试单session需要认证
+     */
+    @Test
+    fun testSingleSessionOnHttp() = startAdapter(
+        "http",
+        verifyKey = verifyKey,
+        enableVerify = true,
+        singleMode = true,
+        debug = false,
+    ) {
+        // 单例 session 未认证
+        testAction(null, StateCode.NotVerifySession)
+
+        val data = VerifyDTO("wrong $verifyKey").toJson()
+        val ret = post<StateCode>(verifyPath, data)
+        assertEquals(StateCode.AuthKeyFail.code, ret.code)
+
+        val correctData = VerifyDTO(verifyKey).toJson()
+        val correctRet = post<VerifyRetDTO>(Paths.httpPath("verify"), correctData)
+        assertEquals(StateCode.Success.code, correctRet.code)
+
+        testAction(null)
+    }
+
+    /**
+     * 测试无需认证的单 Session 模式
+     */
+    @Test
+    fun testSingleSessionOnHttpWithoutAuth() = startAdapter("http",
+        verifyKey = verifyKey,
+        enableVerify = false,
+        singleMode = true,
+        debug = false,
+    ) {
+        // 无需认证的单例 session 模式可以直接访问
+        testAction(null)
+
+        val data = VerifyDTO(verifyKey).toJson()
+        val ret = post<VerifyRetDTO>(verifyPath, data)
+        assertEquals(StateCode.Success.code, ret.code)
+
+        val wrongData = VerifyDTO("wrong $verifyKey").toJson()
+        val wrongRet = post<VerifyRetDTO>(verifyPath, wrongData)
+        assertEquals(StateCode.Success.code, wrongRet.code)
+
+        testAction(null)
+    }
+
+    /**
+     * 测试需认证的通常 Session 模式
+     */
+    @Test
+    fun testAuthNormalSessionOnHttp() = startAdapter("http",
+        verifyKey = verifyKey,
+        enableVerify = true,
+        singleMode = false,
+        debug = false,
+    ) {
+        // 非单例 session 模式下,出现找不到 session 异常
+        testAction("nonexistent session", StateCode.IllegalSession)
+
+        var data = VerifyDTO("wrong $verifyKey").toJson()
+        val wrongRet = post<StateCode>(verifyPath, data)
+        assertEquals(StateCode.AuthKeyFail.code, wrongRet.code)
+
+        data = VerifyDTO(verifyKey).toJson()
+        val verifyRet = post<VerifyRetDTO>(verifyPath, data)
+        assertEquals(StateCode.Success.code, verifyRet.code)
+        assertNotNull(verifyRet.session)
+
+        // 认证但未绑定
+        testAction(verifyRet.session, errorState = StateCode.NotVerifySession)
+
+        // use session to bind
+        data = BindDTO(BotMockStub.ID).toJson()
+        var bindRet: StateCode = post(bindPath, data)
+        assertEquals(StateCode.IllegalSession.code, bindRet.code)
+
+        data = BindDTO(BotMockStub.ID).withSession(verifyRet.session).toJson()
+        bindRet = post(bindPath, data)
+        assertEquals(StateCode.Success.code, bindRet.code)
+
+        testAction(verifyRet.session)
+    }
+
+    @Test
+    fun testAuthNormalSessionOnHttpWithAuth() = startAdapter("http",
+        verifyKey = verifyKey,
+        enableVerify = false,
+        singleMode = false,
+        debug = false,
+    ) {
+        // 非单例 session 模式下,出现找不到 session 异常
+        testAction("nonexistent session", StateCode.IllegalSession)
+
+        // 无需认证key,但仍需要通过认证接口获取 session
+        var data = VerifyDTO("Arbitrary $verifyKey").toJson()
+        val verifyRet = post<VerifyRetDTO>(verifyPath, data)
+        assertEquals(StateCode.Success.code, verifyRet.code)
+        assertNotNull(verifyRet.session)
+
+        // 认证但未绑定
+        testAction(verifyRet.session, errorState = StateCode.NotVerifySession)
+
+        // use session to bind
+        data = BindDTO(BotMockStub.ID).withSession(verifyRet.session).toJson()
+        val bindRet = post<StateCode>(bindPath, data)
+        assertEquals(StateCode.Success.code, bindRet.code)
+
+        testAction(verifyRet.session)
+    }
+
+    private suspend fun AdapterOperation.testAction(sessionKey: String?, errorState: StateCode? = null) {
+        val query = sessionKey?.let {
+            mapOf("sessionKey" to it)
+        } ?: emptyMap()
+
+        if (errorState == null) {
+            val ret = get<ElementResult>(action, query)
+                .data.jsonElementParseOrNull<SessionDTO>()
+            assertNotNull(ret)
+            assertEquals(sessionKey ?: MahContext.SINGLE_SESSION_KEY, ret.sessionKey)
+            assertEquals(BotMockStub.ID, ret.qq.id)
+        } else {
+            val ret = get<StateCode>(action, query)
+            assertEquals(errorState.code, ret.code)
+        }
+    }
+
+}

+ 79 - 0
mirai-api-http/src/test/kotlin/net/mamoe/mirai/api/http/request/http/HttpSessionLifeCycle.kt

@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package net.mamoe.mirai.api.http.request.http
+
+import annotation.ExtendWith
+import extenssion.SetupBotMock
+import kotlinx.coroutines.isActive
+import mock.BotMockStub
+import mock.withSession
+import net.mamoe.mirai.api.http.adapter.common.StateCode
+import net.mamoe.mirai.api.http.adapter.http.session.isHttpSession
+import net.mamoe.mirai.api.http.adapter.internal.consts.Paths
+import net.mamoe.mirai.api.http.adapter.internal.dto.BindDTO
+import net.mamoe.mirai.api.http.adapter.internal.dto.VerifyDTO
+import net.mamoe.mirai.api.http.adapter.internal.dto.VerifyRetDTO
+import net.mamoe.mirai.api.http.adapter.internal.serializer.toJson
+import net.mamoe.mirai.api.http.context.MahContextHolder
+import net.mamoe.mirai.api.http.context.session.Session
+import net.mamoe.mirai.api.http.request.env.startAdapter
+import java.lang.ref.WeakReference
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.test.*
+
+@ExtendWith(SetupBotMock::class)
+open class HttpSessionLifeCycle {
+
+    private val verifyKey = "HttpSessionLifeCycle"
+    private val verifyPath = Paths.httpPath("verify")
+    private val bindPath = Paths.httpPath("bind")
+    private val releasePath = Paths.httpPath("release")
+
+    @Test
+    fun testReleaseSession() = startAdapter(
+        "http",
+        verifyKey = verifyKey,
+        enableVerify = true,
+        singleMode = false,
+    ) {
+        var data = VerifyDTO(verifyKey).toJson()
+        val verifyRet = post<VerifyRetDTO>(verifyPath, data)
+        assertNotNull(verifyRet.session)
+
+        val session = MahContextHolder[verifyRet.session]
+        assertNotNull(session)
+        assertFalse(session.isAuthed)
+        assertFalse(session.isHttpSession())
+
+        data = BindDTO(BotMockStub.ID).withSession(verifyRet.session).toJson()
+        val bindRet = post<StateCode>(bindPath, data)
+        assertEquals(StateCode.Success.code, bindRet.code)
+
+        val authedSession = MahContextHolder[verifyRet.session]
+        assertNotNull(authedSession)
+        assertTrue(authedSession.isAuthed)
+        assertTrue(authedSession.isHttpSession())
+        assertEquals(1, authedSession.getRefCount())
+
+        // same object
+        assertSame(session, authedSession)
+
+        data = BindDTO(BotMockStub.ID).withSession(verifyRet.session).toJson()
+        val releaseRet = post<StateCode>(releasePath, data)
+        assertEquals(StateCode.Success.code, releaseRet.code)
+
+        val releasedSession = MahContextHolder[verifyRet.session]
+        assertNull(releasedSession)
+        assertEquals(0, authedSession.getRefCount())
+
+        assertFalse(authedSession.isActive)
+    }
+}

+ 123 - 0
mirai-api-http/src/test/kotlin/net/mamoe/mirai/api/http/request/ws/WsAuthTest.kt

@@ -0,0 +1,123 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package net.mamoe.mirai.api.http.request.ws
+
+import annotation.ExtendWith
+import extenssion.SetupBotMock
+import kotlinx.coroutines.runBlocking
+import mock.BotMockStub
+import mock.withSession
+import net.mamoe.mirai.api.http.adapter.common.StateCode
+import net.mamoe.mirai.api.http.adapter.internal.consts.Paths
+import net.mamoe.mirai.api.http.adapter.internal.dto.BindDTO
+import net.mamoe.mirai.api.http.adapter.internal.dto.VerifyDTO
+import net.mamoe.mirai.api.http.adapter.internal.dto.VerifyRetDTO
+import net.mamoe.mirai.api.http.adapter.internal.serializer.toJson
+import net.mamoe.mirai.api.http.context.MahContext
+import net.mamoe.mirai.api.http.request.env.startAdapter
+import kotlin.test.*
+
+@ExtendWith(SetupBotMock::class)
+class WsAuthTest {
+
+    private val verifyKey = "session test"
+    private val verifyPath = Paths.httpPath("verify")
+    private val bindPath = Paths.httpPath("bind")
+
+    /**
+     * 单例 session 模式下建立 ws 链接
+     */
+    @Test
+    fun testSingleSessionOnWs() = startAdapter(
+        "ws",
+        verifyKey = verifyKey,
+        enableVerify = true,
+        singleMode = true,
+        debug = false,
+    ) {
+        val stateCode = wsConnect<StateCode>(mapOf("verifyKey" to "wrong $verifyKey"))
+        assertEquals(StateCode.AuthKeyFail.code, stateCode?.code)
+
+        val ret = wsConnect<VerifyRetDTO>(mapOf("verifyKey" to verifyKey))
+        assertEquals(MahContext.SINGLE_SESSION_KEY, ret?.session)
+    }
+
+    /**
+     * 单例 session 模式下无需验证建立链接, 这是最宽松的验证, 完全没有安全性
+     */
+    @Test
+    fun testSingleSessionONWsWithoutAuth() = startAdapter(
+        "ws",
+        verifyKey = verifyKey,
+        enableVerify = false,
+        singleMode = true,
+        debug = false,
+    ) {
+        // connect anyway
+        val ret = wsConnect<VerifyRetDTO>(emptyMap())
+        assertEquals(MahContext.SINGLE_SESSION_KEY, ret?.session)
+    }
+
+    /**
+     * 测试需要认证的 session 模式建立链接, 这是最常用的方案
+     */
+    @Test
+    fun testAuthNormalSessionOnWs() = startAdapter(
+        "ws", "http",
+        verifyKey = verifyKey,
+        enableVerify = true,
+        singleMode = false,
+        debug = false,
+    ) {
+        // 错误 verify key
+        var stateCode: StateCode? = wsConnect(mapOf("verifyKey" to "wrong $verifyKey"))
+        assertEquals(StateCode.AuthKeyFail.code, stateCode?.code)
+
+        // 不绑定账号
+        stateCode = wsConnect(mapOf("verifyKey" to verifyKey))
+        assertEquals(StateCode.InvalidParameter.code, stateCode?.code)
+
+        // 无法绑定账号(绑定错误账号)
+        stateCode = wsConnect(mapOf("verifyKey" to verifyKey, "qq" to "${BotMockStub.ID + 1}"))
+        assertEquals(StateCode.NoBot.code, stateCode?.code)
+
+
+
+        // 通过已有 session 绑定
+
+        // 通过 http 创建一个session
+        val session = post<VerifyRetDTO>(verifyPath, VerifyDTO(verifyKey).toJson()).session
+
+        // 通过 ws 绑定错误 session
+        stateCode = wsConnect(mapOf("verifyKey" to verifyKey, "sessionKey" to "wrong $session"))
+        assertEquals(StateCode.IllegalSession.code, stateCode?.code)
+
+        // 通过 ws 绑定已有未认证 session
+        stateCode = wsConnect(mapOf("verifyKey" to verifyKey, "sessionKey" to session))
+        assertEquals(StateCode.NotVerifySession.code, stateCode?.code)
+
+        // 通过 http 认证 session
+        post<StateCode>(bindPath, BindDTO(BotMockStub.ID).withSession(session).toJson())
+
+        // 通过 ws 绑定已有已认证 session
+        val ret = wsConnect<VerifyRetDTO>(mapOf("verifyKey" to verifyKey, "sessionKey" to session))
+        assertEquals(StateCode.Success.code, ret?.code)
+        assertNotNull(ret?.session)
+        assertNotEquals(MahContext.SINGLE_SESSION_KEY, ret?.session)
+
+        // 通过 ws 创建新 session 并绑定
+        val wsRet = wsConnect<VerifyRetDTO>(mapOf("verifyKey" to verifyKey, "qq" to "${BotMockStub.ID}"))
+        assertEquals(StateCode.Success.code, wsRet?.code)
+        assertNotNull(wsRet?.session)
+        assertNotEquals(MahContext.SINGLE_SESSION_KEY, wsRet?.session)
+        // not same session
+        assertNotEquals(ret?.session, wsRet?.session)
+    }
+}

+ 98 - 0
mirai-api-http/src/test/kotlin/net/mamoe/mirai/api/http/request/ws/WsSessionLifeCycle.kt

@@ -0,0 +1,98 @@
+/*
+ * Copyright 2020 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package net.mamoe.mirai.api.http.request.ws
+
+import extenssion.SetupBotMock
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import mock.BotMockStub
+import mock.withSession
+import net.mamoe.mirai.api.http.adapter.common.StateCode
+import net.mamoe.mirai.api.http.adapter.internal.consts.Paths
+import net.mamoe.mirai.api.http.adapter.internal.dto.BindDTO
+import net.mamoe.mirai.api.http.adapter.internal.dto.VerifyDTO
+import net.mamoe.mirai.api.http.adapter.internal.dto.VerifyRetDTO
+import net.mamoe.mirai.api.http.adapter.internal.serializer.toJson
+import net.mamoe.mirai.api.http.context.MahContextHolder
+import net.mamoe.mirai.api.http.request.env.startAdapter
+import org.junit.jupiter.api.extension.ExtendWith
+import kotlin.test.*
+
+@ExtendWith(SetupBotMock::class)
+class WsSessionLifeCycle {
+
+    private val verifyKey = "HttpSessionLifeCycle"
+    private val verifyPath = Paths.httpPath("verify")
+    private val bindPath = Paths.httpPath("bind")
+    private val releasePath = Paths.httpPath("release")
+
+    @Test
+    fun testDisconnectSession() = startAdapter(
+        "ws",
+        verifyKey = verifyKey,
+        enableVerify = true,
+        singleMode = false,
+    ) {
+        // 通过 ws 创建新 session 并绑定
+        val wsRet = wsConnect<VerifyRetDTO>(mapOf("verifyKey" to verifyKey, "qq" to "${BotMockStub.ID}"))
+        val sessionKey = wsRet?.session
+        assertNotNull(sessionKey)
+
+        // after disconnect
+        val session = MahContextHolder[sessionKey]
+        assertNull(session)
+    }
+
+    @Test
+    fun testDisconnectSessionFromHttp() = startAdapter(
+        "ws", "http",
+        verifyKey = verifyKey,
+        enableVerify = true,
+        singleMode = false,
+    ) {
+        // 创建 http session
+        var data = VerifyDTO(verifyKey).toJson()
+        val verifyRet = post<VerifyRetDTO>(verifyPath, data)
+        assertNotNull(verifyRet.session)
+
+        val session = MahContextHolder[verifyRet.session]
+        assertNotNull(session)
+        assertEquals(0, session.getRefCount())
+
+        // 认证 http session 并引用
+        data = BindDTO(BotMockStub.ID).withSession(verifyRet.session).toJson()
+        val bindRet = post<StateCode>(bindPath, data)
+        assertEquals(StateCode.Success.code, bindRet.code)
+        assertEquals(1, session.getRefCount())
+
+        // 通过 websocket 复用 session
+        val wsRet = wsConnect<VerifyRetDTO>(mapOf("verifyKey" to verifyKey, "sessionKey" to verifyRet.session))
+        val sessionKey = wsRet?.session
+        assertEquals(verifyRet.session, sessionKey)
+
+        // socket 由客户端主动断开, 服务端需要一定时间感知
+        delay(100)
+
+        // websocket 断开时, session 引用依旧被 http 引用保留
+        assertEquals(1, session.getRefCount())
+        assertFalse(session.isClosed)
+        assertTrue(session.isActive)
+
+        // http 释放 session
+        data = BindDTO(BotMockStub.ID).withSession(verifyRet.session).toJson()
+        val releaseRet = post<StateCode>(releasePath, data)
+        assertEquals(StateCode.Success.code, releaseRet.code)
+        assertEquals(0, session.getRefCount())
+
+        // session 被回收
+        assertTrue(session.isClosed)
+        assertFalse(session.isActive)
+    }
+}