mirror of
https://gitlab.com/mangadex-pub/mangadex_at_home.git
synced 2024-01-19 02:48:37 +00:00
295 lines
10 KiB
Kotlin
295 lines
10 KiB
Kotlin
|
/*
|
||
|
Mangadex@Home
|
||
|
Copyright (c) 2020, MangaDex Network
|
||
|
This file is part of MangaDex@Home.
|
||
|
|
||
|
MangaDex@Home is free software: you can redistribute it and/or modify
|
||
|
it under the terms of the GNU General Public License as published by
|
||
|
the Free Software Foundation, either version 3 of the License, or
|
||
|
(at your option) any later version.
|
||
|
|
||
|
MangaDex@Home is distributed in the hope that it will be useful,
|
||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
|
GNU General Public License for more details.
|
||
|
|
||
|
You should have received a copy of the GNU General Public License
|
||
|
along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
|
||
|
*/
|
||
|
/* ktlint-disable no-wildcard-imports */
|
||
|
package mdnet.server
|
||
|
|
||
|
import io.kotest.assertions.withClue
|
||
|
import io.kotest.core.spec.IsolationMode
|
||
|
import io.kotest.core.spec.style.FreeSpec
|
||
|
import io.kotest.engine.spec.tempdir
|
||
|
import io.kotest.engine.spec.tempfile
|
||
|
import io.kotest.matchers.shouldBe
|
||
|
import io.micrometer.prometheus.PrometheusConfig
|
||
|
import io.micrometer.prometheus.PrometheusMeterRegistry
|
||
|
import io.mockk.confirmVerified
|
||
|
import io.mockk.every
|
||
|
import io.mockk.mockk
|
||
|
import io.mockk.verify
|
||
|
import kotlinx.coroutines.delay
|
||
|
import mdnet.cache.ImageStorage
|
||
|
import mdnet.data.Statistics
|
||
|
import mdnet.security.TweetNaclFast
|
||
|
import org.apache.commons.io.IOUtils
|
||
|
import org.http4k.core.HttpHandler
|
||
|
import org.http4k.core.Method
|
||
|
import org.http4k.core.Request
|
||
|
import org.http4k.core.Response
|
||
|
import org.http4k.core.Status
|
||
|
import org.http4k.core.then
|
||
|
import org.http4k.kotest.shouldHaveHeader
|
||
|
import org.http4k.kotest.shouldHaveStatus
|
||
|
import org.http4k.kotest.shouldNotHaveStatus
|
||
|
import org.http4k.routing.bind
|
||
|
import org.http4k.routing.routes
|
||
|
import org.ktorm.database.Database
|
||
|
import java.io.ByteArrayInputStream
|
||
|
import java.util.concurrent.atomic.AtomicReference
|
||
|
|
||
|
class ImageServerTest : FreeSpec() {
|
||
|
override fun isolationMode() = IsolationMode.InstancePerTest
|
||
|
|
||
|
init {
|
||
|
val registry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)
|
||
|
val mockData = byteArrayOf(72, 66, 67, 66, 65, 66, 73, 69, 65, 67)
|
||
|
|
||
|
"correct upstream responses" - {
|
||
|
val client = mockk<HttpHandler>()
|
||
|
every {
|
||
|
client(any())
|
||
|
} answers {
|
||
|
correctMockResponse(mockData)
|
||
|
}
|
||
|
|
||
|
"mocked noop cache" - {
|
||
|
val storage = mockk<ImageStorage>()
|
||
|
every {
|
||
|
storage.loadImage(any())
|
||
|
} returns null
|
||
|
every {
|
||
|
storage.storeImage(any(), any())
|
||
|
} returns null
|
||
|
|
||
|
val server = ImageServer(
|
||
|
storage,
|
||
|
AtomicReference(Statistics()),
|
||
|
client,
|
||
|
registry
|
||
|
)
|
||
|
|
||
|
val handler = routes(
|
||
|
"/{token}/data/{chapterHash}/{fileName}" bind Method.GET to server.handler(
|
||
|
dataSaver = false,
|
||
|
),
|
||
|
"/{token}/data-saver/{chapterHash}/{fileName}" bind Method.GET to server.handler(
|
||
|
dataSaver = true,
|
||
|
),
|
||
|
"/data/{chapterHash}/{fileName}" bind Method.GET to server.handler(
|
||
|
dataSaver = false,
|
||
|
),
|
||
|
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to server.handler(
|
||
|
dataSaver = true,
|
||
|
),
|
||
|
)
|
||
|
|
||
|
"directly proxied" {
|
||
|
for (i in 0..2) {
|
||
|
val response = handler(Request(Method.GET, "/data/02181a8f5fe8cd408720a771dd129fd8/T2.png"))
|
||
|
|
||
|
withClue("should be directly proxied") {
|
||
|
verify(exactly = i + 1) {
|
||
|
client(any())
|
||
|
}
|
||
|
confirmVerified(client)
|
||
|
}
|
||
|
|
||
|
response.shouldHaveStatus(Status.OK)
|
||
|
response.shouldHaveHeader("Content-Length", mockData.size.toString())
|
||
|
IOUtils.toByteArray(response.body.stream).shouldBe(mockData)
|
||
|
response.close()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
"with real cache" - {
|
||
|
val storage = ImageStorage(
|
||
|
maxSize = 100000,
|
||
|
cacheDirectory = tempdir().toPath(),
|
||
|
database = Database.connect("jdbc:h2:${tempfile()}"),
|
||
|
autoPrune = false,
|
||
|
)
|
||
|
|
||
|
val server = ImageServer(
|
||
|
storage,
|
||
|
AtomicReference(Statistics()),
|
||
|
client,
|
||
|
registry
|
||
|
)
|
||
|
|
||
|
val handler = routes(
|
||
|
"/data/{chapterHash}/{fileName}" bind Method.GET to server.handler(
|
||
|
dataSaver = false,
|
||
|
),
|
||
|
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to server.handler(
|
||
|
dataSaver = true,
|
||
|
),
|
||
|
)
|
||
|
|
||
|
"respects cache" {
|
||
|
for (i in 0..2) {
|
||
|
val response = handler(Request(Method.GET, "/data/02181a8f5fe8cd408720a771dd129fd8/T2.png"))
|
||
|
|
||
|
withClue("should only be downloaded once") {
|
||
|
verify(exactly = 1) {
|
||
|
client(any())
|
||
|
}
|
||
|
confirmVerified(client)
|
||
|
}
|
||
|
|
||
|
response.shouldHaveStatus(Status.OK)
|
||
|
response.shouldHaveHeader("Content-Length", mockData.size.toString())
|
||
|
IOUtils.toByteArray(response.body.stream).shouldBe(mockData)
|
||
|
response.close()
|
||
|
|
||
|
// wait for the executor to commit
|
||
|
delay(100)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
"failed upstream responses" - {
|
||
|
val client = mockk<HttpHandler>()
|
||
|
|
||
|
val storage = ImageStorage(
|
||
|
maxSize = 100000,
|
||
|
cacheDirectory = tempdir().toPath(),
|
||
|
database = Database.connect("jdbc:h2:${tempfile()}"),
|
||
|
autoPrune = false,
|
||
|
)
|
||
|
|
||
|
val server = ImageServer(
|
||
|
storage,
|
||
|
AtomicReference(Statistics()),
|
||
|
client,
|
||
|
registry
|
||
|
)
|
||
|
|
||
|
val handler = routes(
|
||
|
"/data/{chapterHash}/{fileName}" bind Method.GET to server.handler(
|
||
|
dataSaver = false,
|
||
|
),
|
||
|
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to server.handler(
|
||
|
dataSaver = true,
|
||
|
),
|
||
|
)
|
||
|
|
||
|
"does not cache failures" {
|
||
|
val errStatus = Status.NOT_FOUND
|
||
|
every {
|
||
|
client(any())
|
||
|
} returns Response(errStatus)
|
||
|
|
||
|
for (i in 0..2) {
|
||
|
val response = handler(Request(Method.GET, "/data/02181a8f5fe8cd408720a771dd129fd8/T2.png"))
|
||
|
|
||
|
withClue("should be directly proxied") {
|
||
|
verify(exactly = i + 1) {
|
||
|
client(any())
|
||
|
}
|
||
|
confirmVerified(client)
|
||
|
}
|
||
|
|
||
|
response.shouldHaveStatus(errStatus)
|
||
|
response.close()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
"errors on bad content type" {
|
||
|
every {
|
||
|
client(any())
|
||
|
} answers {
|
||
|
correctMockResponse(mockData)
|
||
|
.replaceHeader("Content-Type", "text/html")
|
||
|
}
|
||
|
|
||
|
for (i in 0..2) {
|
||
|
val response = handler(Request(Method.GET, "/data/02181a8f5fe8cd408720a771dd129fd8/T2.png"))
|
||
|
|
||
|
withClue("should be directly proxied") {
|
||
|
verify(exactly = i + 1) {
|
||
|
client(any())
|
||
|
}
|
||
|
confirmVerified(client)
|
||
|
}
|
||
|
|
||
|
response.status.shouldBe(Status.INTERNAL_SERVER_ERROR)
|
||
|
response.close()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
"still works on no content-length" {
|
||
|
every {
|
||
|
client(any())
|
||
|
} answers {
|
||
|
correctMockResponse(mockData)
|
||
|
.removeHeader("Content-Length")
|
||
|
}
|
||
|
|
||
|
for (i in 0..2) {
|
||
|
val response = handler(Request(Method.GET, "/data/02181a8f5fe8cd408720a771dd129fd8/T2.png"))
|
||
|
|
||
|
response.shouldHaveStatus(Status.OK)
|
||
|
IOUtils.toByteArray(response.body.stream).shouldBe(mockData)
|
||
|
response.close()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun correctMockResponse(data: ByteArray) =
|
||
|
Response(Status.OK)
|
||
|
.body(ByteArrayInputStream(data))
|
||
|
.header("Content-Type", "image/jpg")
|
||
|
.header("Content-Length", "${data.size}")
|
||
|
.header("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
|
||
|
}
|
||
|
|
||
|
class TokenVerifierTest : FreeSpec() {
|
||
|
init {
|
||
|
val remoteKeys = TweetNaclFast.Box.keyPair()
|
||
|
val clientKeys = TweetNaclFast.Box.keyPair()
|
||
|
val box = TweetNaclFast.Box(clientKeys.publicKey, remoteKeys.secretKey)
|
||
|
|
||
|
val backend = tokenVerifier(box.before()) { _, _ ->
|
||
|
true
|
||
|
}.then {
|
||
|
Response(Status.OK)
|
||
|
}
|
||
|
|
||
|
val handler = routes(
|
||
|
"/data/{chapterHash}/{fileName}" bind Method.GET to backend,
|
||
|
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to backend,
|
||
|
"/{token}/data/{chapterHash}/{fileName}" bind Method.GET to backend,
|
||
|
"/{token}/data-saver/{chapterHash}/{fileName}" bind Method.GET to backend,
|
||
|
)
|
||
|
|
||
|
"invalid" - {
|
||
|
"missing token should fail" {
|
||
|
val response = handler(Request(Method.GET, "/data/02181a8f5fe8cd408720a771dd129fd8/T2.png"))
|
||
|
response.shouldNotHaveStatus(Status.OK)
|
||
|
}
|
||
|
|
||
|
"too short token should fail" {
|
||
|
val response = handler(Request(Method.GET, "/a/data/02181a8f5fe8cd408720a771dd129fd8/T2.png"))
|
||
|
response.shouldNotHaveStatus(Status.OK)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|