fdroid-client/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt

267 lines
10 KiB
Kotlin

package org.fdroid.download
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.ProxyBuilder
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockEngineConfig
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.engine.mock.respondOk
import io.ktor.client.engine.mock.respondRedirect
import io.ktor.client.features.RedirectResponseException
import io.ktor.client.features.ServerResponseException
import io.ktor.http.HttpHeaders.Authorization
import io.ktor.http.HttpHeaders.ETag
import io.ktor.http.HttpHeaders.Range
import io.ktor.http.HttpHeaders.UserAgent
import io.ktor.http.HttpStatusCode.Companion.InternalServerError
import io.ktor.http.HttpStatusCode.Companion.OK
import io.ktor.http.HttpStatusCode.Companion.PartialContent
import io.ktor.http.HttpStatusCode.Companion.TemporaryRedirect
import io.ktor.http.Url
import io.ktor.http.headersOf
import org.fdroid.get
import org.fdroid.getRandomString
import org.fdroid.runSuspend
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.fail
class HttpManagerTest {
private val userAgent = getRandomString()
private val mirrors = listOf(Mirror("http://example.org"), Mirror("http://example.net/"))
private val downloadRequest = DownloadRequest("foo", mirrors)
@Test
fun testUserAgent() = runSuspend {
val mockEngine = MockEngine { respondOk() }
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine))
httpManager.head(downloadRequest)
httpManager.getBytes(downloadRequest)
mockEngine.requestHistory.forEach { request ->
assertEquals(userAgent, request.headers[UserAgent])
}
}
@Test
fun testQueryString() = runSuspend {
val id = getRandomString()
val version = getRandomString()
val queryString = "id=$id&client_version=$version"
val mockEngine = MockEngine { respondOk() }
val httpManager =
HttpManager(userAgent, queryString, httpClientEngineFactory = get(mockEngine))
httpManager.head(downloadRequest)
httpManager.getBytes(downloadRequest)
mockEngine.requestHistory.forEach { request ->
assertEquals(id, request.url.parameters["id"])
assertEquals(version, request.url.parameters["client_version"])
}
}
@Test
fun testBasicAuth() = runSuspend {
val downloadRequest = DownloadRequest("foo", mirrors, null, "Foo", "Bar")
val mockEngine = MockEngine { respondOk() }
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine))
httpManager.head(downloadRequest)
httpManager.getBytes(downloadRequest)
mockEngine.requestHistory.forEach { request ->
assertEquals("Basic Rm9vOkJhcg==", request.headers[Authorization])
}
}
@Test
fun testHeadETagCheck() = runSuspend {
val eTag = getRandomString()
val headers = headersOf(ETag, eTag)
val mockEngine = MockEngine { respond("", headers = headers) }
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine))
// ETag is considered changed when none (null) passed into the request
assertTrue(httpManager.head(downloadRequest)!!.eTagChanged)
// Random ETag will be different than what we expect
assertTrue(httpManager.head(downloadRequest, getRandomString())!!.eTagChanged)
// Expected ETag should match response, so it hasn't changed
assertFalse(httpManager.head(downloadRequest, eTag)!!.eTagChanged)
}
@Test
fun testDownload() = runSuspend {
val content = Random.nextBytes(1024)
val mockEngine = MockEngine { respond(content) }
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine))
assertContentEquals(content, httpManager.getBytes(downloadRequest))
}
@Test
fun testResumeDownload() = runSuspend {
val skipBytes = Random.nextInt(0, 1024)
val content = Random.nextBytes(1024)
var requestNum = 1
val mockEngine = MockEngine { request ->
assertNotNull(request.headers[Range])
val (fromStr, endStr) = request.headers[Range]!!
.replace("bytes=", "")
.split('-')
val from =
fromStr.toIntOrNull() ?: fail("No valid content range ${request.headers[Range]}")
assertEquals("", endStr)
assertEquals(skipBytes, from)
if (requestNum++ == 1) respond(content.copyOfRange(from, content.size), PartialContent)
else respond(content, OK)
}
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine))
// first request gets only the skipped bytes
assertContentEquals(
content.copyOfRange(skipBytes, content.size),
httpManager.getBytes(downloadRequest, skipBytes.toLong())
)
// second request fails, because it responds with OK and full content
assertFailsWith<NoResumeException> {
httpManager.getBytes(downloadRequest, skipBytes.toLong())
}
}
@Test
fun testMirrorFallback() = runSuspend {
val mockEngine = MockEngine { respondError(InternalServerError) }
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine))
assertNull(httpManager.head(downloadRequest))
assertFailsWith<ServerResponseException> {
httpManager.getBytes(downloadRequest)
}
// assert that URLs for each mirror get tried
val urls = mockEngine.requestHistory.map { request -> request.url.toString() }.toSet()
assertEquals(setOf("http://example.org/foo", "http://example.net/foo"), urls)
}
@Test
fun testFirstMirrorSuccess() = runSuspend {
val mockEngine = MockEngine { respondOk() }
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine))
assertNotNull(httpManager.head(downloadRequest))
httpManager.getBytes(downloadRequest)
// assert there is only one request per API call using one of the mirrors
assertEquals(2, mockEngine.requestHistory.size)
mockEngine.requestHistory.forEach { request ->
val url = request.url.toString()
assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo")
}
}
@Test
fun testNoRedirect() = runSuspend {
val mockEngine = MockEngine { respondRedirect("http://example.com") }
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine))
assertNull(httpManager.head(downloadRequest))
assertFailsWith<RedirectResponseException> {
httpManager.getBytes(downloadRequest)
}
// HEAD tries another mirror, but GET throws, so no retry
assertEquals(3, mockEngine.requestHistory.size)
mockEngine.responseHistory.forEach { response ->
assertEquals(TemporaryRedirect, response.statusCode)
}
}
@Test
fun testProxyGetsApplied() = runSuspend {
val proxyConfig = ProxyBuilder.http(Url("http://127.0.0.1:5050"))
val proxyRequest = DownloadRequest("foo", mirrors, proxyConfig)
val noProxyRequest = DownloadRequest("foo", mirrors)
var numRequests = 0
val factory = object : HttpClientEngineFactory<MockEngineConfig> {
override fun create(block: MockEngineConfig.() -> Unit): HttpClientEngine {
return when (++numRequests) {
1 -> MockEngine { respondOk() }
2 -> MockEngine { respondOk() }
3 -> MockEngine { respondOk() }
else -> fail("Too many engine creations")
}
}
}
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = factory)
assertNull(httpManager.currentProxy)
// does not need a new engine, because also doesn't use a proxy
assertNotNull(httpManager.head(noProxyRequest))
assertNull(httpManager.currentProxy)
// now wants proxy, creates new engine (2)
assertNotNull(httpManager.head(proxyRequest))
assertEquals(proxyConfig, httpManager.currentProxy)
// no more proxy, creates new engine (3)
httpManager.getBytes(noProxyRequest)
assertNull(httpManager.currentProxy)
assertEquals(3, numRequests)
}
@Test
fun testNoProxyWithLocalMirror() = runSuspend {
val mirror = Mirror("http://192.168.49.5")
assertTrue(mirror.isLocal())
val proxyConfig = ProxyBuilder.http(Url("http://127.0.0.1:5050"))
val localRequest = DownloadRequest("foo", listOf(mirror), proxyConfig)
val internetRequest = DownloadRequest("foo", mirrors, proxyConfig)
var numEngines = 0
val factory = object : HttpClientEngineFactory<MockEngineConfig> {
override fun create(block: MockEngineConfig.() -> Unit): HttpClientEngine {
return when (++numEngines) {
1 -> MockEngine { respondOk() }
2 -> MockEngine { respondOk() }
else -> fail("Too many engine creations")
}
}
}
val httpManager =
HttpManager(userAgent, null, proxyConfig, httpClientEngineFactory = factory)
assertEquals(proxyConfig, httpManager.currentProxy)
// does not need a new engine, because also does use a proxy (1)
assertNotNull(httpManager.head(internetRequest))
assertEquals(proxyConfig, httpManager.currentProxy)
// now no proxy, because local mirror, creates new engine (2)
assertNotNull(httpManager.head(localRequest))
assertNull(httpManager.currentProxy)
// still no proxy, because local mirror as well, should not create new engine
assertNotNull(httpManager.getBytes(localRequest))
assertNull(httpManager.currentProxy)
assertEquals(2, numEngines)
}
}