Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,84 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2021 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
package eu.qsfera.android.lib
import eu.qsfera.android.lib.common.http.CookieJarImpl
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CookieJarImplTest {
private val oldCookies = listOf(COOKIE_A, COOKIE_B_OLD)
private val newCookies = listOf(COOKIE_B_NEW)
private val updatedCookies = listOf(COOKIE_A, COOKIE_B_NEW)
private val cookieStore = hashMapOf(SOME_HOST to oldCookies)
private val cookieJarImpl = CookieJarImpl(cookieStore)
@Test
fun `contains cookie with name - ok - true`() {
assertTrue(cookieJarImpl.containsCookieWithName(oldCookies, COOKIE_B_OLD.name))
}
@Test
fun `contains cookie with name - ok - false`() {
assertFalse(cookieJarImpl.containsCookieWithName(newCookies, COOKIE_A.name))
}
@Test
fun `get updated cookies - ok`() {
val generatedUpdatedCookies = cookieJarImpl.getUpdatedCookies(oldCookies, newCookies)
assertEquals(2, generatedUpdatedCookies.size)
assertEquals(updatedCookies[0], generatedUpdatedCookies[1])
assertEquals(updatedCookies[1], generatedUpdatedCookies[0])
}
@Test
fun `store cookie via saveFromResponse - ok`() {
cookieJarImpl.saveFromResponse(SOME_URL, newCookies)
val generatedUpdatedCookies = cookieStore[SOME_HOST]
assertEquals(2, generatedUpdatedCookies?.size)
assertEquals(updatedCookies[0], generatedUpdatedCookies?.get(1))
assertEquals(updatedCookies[1], generatedUpdatedCookies?.get(0))
}
@Test
fun `load for request - ok`() {
val cookies = cookieJarImpl.loadForRequest(SOME_URL)
assertEquals(oldCookies[0], cookies[0])
assertEquals(oldCookies[1], cookies[1])
}
companion object {
const val SOME_HOST = "some.host.com"
val SOME_URL = "https://$SOME_HOST".toHttpUrl()
val COOKIE_A = Cookie.parse(SOME_URL, "CookieA=CookieValueA")!!
val COOKIE_B_OLD = Cookie.parse(SOME_URL, "CookieB=CookieOldValueB")!!
val COOKIE_B_NEW = Cookie.parse(SOME_URL, "CookieB=CookieNewValueB")!!
}
}
@@ -0,0 +1,145 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2021 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
package eu.qsfera.android.lib
import android.net.Uri
import android.os.Build
import eu.qsfera.android.lib.resources.status.GetRemoteStatusOperation
import eu.qsfera.android.lib.resources.status.HttpScheme.HTTPS_PREFIX
import eu.qsfera.android.lib.resources.status.HttpScheme.HTTP_PREFIX
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE)
class GetRemoteStatusOperationTest {
@Test
fun `uses http or https - ok - http`() {
assertTrue(GetRemoteStatusOperation.usesHttpOrHttps(Uri.parse(HTTP_SOME_QSFERA)))
}
@Test
fun `uses http or https - ok - https`() {
assertTrue(GetRemoteStatusOperation.usesHttpOrHttps(Uri.parse(HTTPS_SOME_QSFERA)))
}
@Test
fun `uses http or https - ok - no http or https`() {
assertFalse(GetRemoteStatusOperation.usesHttpOrHttps(Uri.parse(SOME_QSFERA)))
}
@Test
fun `build full https url - ok - http`() {
assertEquals(
Uri.parse(HTTP_SOME_QSFERA),
GetRemoteStatusOperation.buildFullHttpsUrl(Uri.parse(HTTP_SOME_QSFERA))
)
}
@Test
fun `build full https url - ok - https`() {
assertEquals(
Uri.parse(HTTPS_SOME_QSFERA),
GetRemoteStatusOperation.buildFullHttpsUrl(Uri.parse(HTTPS_SOME_QSFERA))
)
}
@Test
fun `build full https url - ok - no prefix`() {
assertEquals(
Uri.parse(HTTPS_SOME_QSFERA),
GetRemoteStatusOperation.buildFullHttpsUrl(Uri.parse(SOME_QSFERA))
)
}
@Test
fun `build full https url - ok - no https with subdir`() {
assertEquals(
Uri.parse(HTTPS_SOME_QSFERA_WITH_SUBDIR),
GetRemoteStatusOperation.buildFullHttpsUrl(
Uri.parse(HTTPS_SOME_QSFERA_WITH_SUBDIR)
)
)
}
@Test
fun `build full https url - ok - no prefix with subdir`() {
assertEquals(
Uri.parse(HTTPS_SOME_QSFERA_WITH_SUBDIR),
GetRemoteStatusOperation.buildFullHttpsUrl(
Uri.parse(SOME_QSFERA_WITH_SUBDIR)
)
)
}
@Test
fun `build full https url - ok - ip`() {
assertEquals(Uri.parse(HTTPS_SOME_IP), GetRemoteStatusOperation.buildFullHttpsUrl(Uri.parse(SOME_IP)))
}
@Test
fun `build full https url - ok - http ip`() {
assertEquals(Uri.parse(HTTP_SOME_IP), GetRemoteStatusOperation.buildFullHttpsUrl(Uri.parse(HTTP_SOME_IP)))
}
@Test
fun `build full https url - ok - ip with port`() {
assertEquals(
Uri.parse(HTTPS_SOME_IP_WITH_PORT),
GetRemoteStatusOperation.buildFullHttpsUrl(Uri.parse(SOME_IP_WITH_PORT))
)
}
@Test
fun `build full https url - ok - ip with http and port`() {
assertEquals(
Uri.parse(HTTP_SOME_IP_WITH_PORT),
GetRemoteStatusOperation.buildFullHttpsUrl(Uri.parse(HTTP_SOME_IP_WITH_PORT))
)
}
companion object {
const val SOME_QSFERA = "some_qsfera.eu"
const val HTTP_SOME_QSFERA = "$HTTP_PREFIX$SOME_QSFERA"
const val HTTPS_SOME_QSFERA = "$HTTPS_PREFIX$SOME_QSFERA"
const val SOME_QSFERA_WITH_SUBDIR = "some_qsfera.eu/subdir"
const val HTTP_SOME_QSFERA_WITH_SUBDIR = "$HTTP_PREFIX$SOME_QSFERA_WITH_SUBDIR"
const val HTTPS_SOME_QSFERA_WITH_SUBDIR = "$HTTPS_PREFIX$SOME_QSFERA_WITH_SUBDIR"
const val SOME_IP = "184.123.185.12"
const val HTTP_SOME_IP = "$HTTP_PREFIX$SOME_IP"
const val HTTPS_SOME_IP = "$HTTPS_PREFIX$SOME_IP"
const val SOME_IP_WITH_PORT = "184.123.185.12:5678"
const val HTTP_SOME_IP_WITH_PORT = "$HTTP_PREFIX$SOME_IP_WITH_PORT"
const val HTTPS_SOME_IP_WITH_PORT = "$HTTPS_PREFIX$SOME_IP_WITH_PORT"
}
}
@@ -0,0 +1,59 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2023 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
package eu.qsfera.android.lib
import android.os.Build
import eu.qsfera.android.lib.resources.files.RemoteFile
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE)
class RemoteFileTest {
@Test
fun getRemotePathFromUrl_legacyWebDav() {
val httpUrlToTest = "https://server.url/remote.php/dav/files/username/Documents/text.txt".toHttpUrl()
val expectedRemotePath = "/Documents/text.txt"
val actualRemotePath = RemoteFile.Companion.getRemotePathFromUrl(httpUrlToTest, "username")
assertEquals(expectedRemotePath, actualRemotePath)
}
@Test
fun getRemotePathFromUrl_spacesWebDav() {
val spaceWebDavUrl = "https://server.url/dav/spaces/8871f4f3-fc6f-4a66-8bed-62f175f76f38$05bca744-d89f-4e9c-a990-25a0d7f03fe9"
val httpUrlToTest =
"https://server.url/dav/spaces/8871f4f3-fc6f-4a66-8bed-62f175f76f38$05bca744-d89f-4e9c-a990-25a0d7f03fe9/Documents/text.txt".toHttpUrl()
val expectedRemotePath = "/Documents/text.txt"
val actualRemotePath = RemoteFile.Companion.getRemotePathFromUrl(httpUrlToTest, "username", spaceWebDavUrl)
assertEquals(expectedRemotePath, actualRemotePath)
}
}
@@ -0,0 +1,104 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2021 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
package eu.qsfera.android.lib
import eu.qsfera.android.lib.resources.status.StatusRequester
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class StatusRequesterTest {
private val requester = StatusRequester()
@Test
fun `update location - ok - absolute path`() {
val newLocation = requester.updateLocationWithRedirectPath(TEST_DOMAIN, "$TEST_DOMAIN$SUB_PATH")
assertEquals("$TEST_DOMAIN$SUB_PATH", newLocation)
}
@Test
fun `update location - ok - smaller absolute path`() {
val newLocation = requester.updateLocationWithRedirectPath("$TEST_DOMAIN$SUB_PATH", TEST_DOMAIN)
assertEquals(TEST_DOMAIN, newLocation)
}
@Test
fun `update location - ok - relative path`() {
val newLocation = requester.updateLocationWithRedirectPath(TEST_DOMAIN, SUB_PATH)
assertEquals("$TEST_DOMAIN$SUB_PATH", newLocation)
}
@Test
fun `update location - ok - replace relative path`() {
val newLocation = requester.updateLocationWithRedirectPath("$TEST_DOMAIN/some/other/subdir", SUB_PATH)
assertEquals("$TEST_DOMAIN$SUB_PATH", newLocation)
}
@Test
fun `check redirect to unsecure connection - ok - redirect to http`() {
assertTrue(
requester.isRedirectedToNonSecureConnection(false, SECURE_DOMAIN, UNSECURE_DOMAIN
)
)
}
@Test
fun `check redirect to unsecure connection - ko - redirect to https from http`() {
assertFalse(
requester.isRedirectedToNonSecureConnection(false, UNSECURE_DOMAIN, SECURE_DOMAIN
)
)
}
@Test
fun `check redirect to unsecure connection - ko - from https to https`() {
assertFalse(
requester.isRedirectedToNonSecureConnection(false, SECURE_DOMAIN, SECURE_DOMAIN)
)
}
@Test
fun `check redirect to unsecure connection - ok - from https to https with previous http`() {
assertTrue(
requester.isRedirectedToNonSecureConnection(true, SECURE_DOMAIN, SECURE_DOMAIN)
)
}
@Test
fun `check redirect to unsecure connection - ok - from http to http`() {
assertFalse(
requester.isRedirectedToNonSecureConnection(false, UNSECURE_DOMAIN, UNSECURE_DOMAIN)
)
}
companion object {
const val TEST_DOMAIN = "https://cloud.somewhere.com"
const val SUB_PATH = "/subdir"
const val SECURE_DOMAIN = "https://cloud.somewhere.com"
const val UNSECURE_DOMAIN = "http://somewhereelse.org"
}
}
@@ -0,0 +1,128 @@
package eu.qsfera.android.lib.common.http
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import eu.qsfera.android.lib.common.network.CertificateCombinedException
import eu.qsfera.android.lib.common.network.NetworkUtils
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.tls.HandshakeCertificates
import okhttp3.tls.HeldCertificate
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.lang.reflect.Field
import javax.net.ssl.SSLPeerUnverifiedException
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE)
class HttpClientTlsTest {
private val context by lazy { ApplicationProvider.getApplicationContext<Context>() }
private lateinit var server: MockWebServer
@Before
fun setUp() {
resetKnownServersStore()
server = MockWebServer()
}
@After
fun tearDown() {
runCatching { server.shutdown() }
resetKnownServersStore()
}
@Test
fun `accepts user-trusted certificate despite hostname mismatch`() {
val wrongHostnameCertificate = HeldCertificate.Builder()
.commonName(WRONG_HOSTNAME)
.addSubjectAlternativeName(WRONG_HOSTNAME)
.build()
val serverCertificates = HandshakeCertificates.Builder()
.heldCertificate(wrongHostnameCertificate)
.build()
server.useHttps(serverCertificates.sslSocketFactory(), false)
server.enqueue(MockResponse().setResponseCode(200).setBody("ok"))
server.start()
NetworkUtils.addCertToKnownServersStore(wrongHostnameCertificate.certificate, context)
val request = Request.Builder()
.url(server.url("/"))
.build()
TestHttpClient(context).okHttpClient.newCall(request).execute().use { response ->
assertEquals(200, response.code)
assertEquals("ok", response.body?.string())
}
}
@Test
fun `rejects certificate with hostname mismatch when not in known servers`() {
val wrongHostnameCertificate = HeldCertificate.Builder()
.commonName(WRONG_HOSTNAME)
.addSubjectAlternativeName(WRONG_HOSTNAME)
.build()
val serverCertificates = HandshakeCertificates.Builder()
.heldCertificate(wrongHostnameCertificate)
.build()
server.useHttps(serverCertificates.sslSocketFactory(), false)
server.enqueue(MockResponse().setResponseCode(200).setBody("ok"))
server.start()
val request = Request.Builder()
.url(server.url("/"))
.build()
val thrown = assertThrows(Exception::class.java) {
TestHttpClient(context).okHttpClient.newCall(request).execute().use { }
}
assertNotNull(thrown)
}
@Test
fun `wraps hostname mismatch as certificate combined exception`() {
val peerUnverifiedException = SSLPeerUnverifiedException("Hostname localhost not verified")
val result = RemoteOperationResult<Unit>(peerUnverifiedException)
assertEquals(
RemoteOperationResult.ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED,
result.code
)
assertTrue(result.exception is CertificateCombinedException)
val combinedException = result.exception as CertificateCombinedException
assertSame(peerUnverifiedException, combinedException.sslPeerUnverifiedException)
}
private fun resetKnownServersStore() {
context.deleteFile(KNOWN_SERVERS_STORE_FILE)
val field: Field = NetworkUtils::class.java.getDeclaredField("mKnownServersStore")
field.isAccessible = true
field.set(null, null)
}
private class TestHttpClient(context: Context) : HttpClient(context)
companion object {
private const val WRONG_HOSTNAME = "wrong.example.test"
private const val KNOWN_SERVERS_STORE_FILE = "knownServers.bks"
}
}
@@ -0,0 +1,56 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
package eu.qsfera.android.lib.common.http.logging
import org.junit.Assert.assertEquals
import org.junit.Test
class LogInterceptorTest {
@Test
fun durationStringFormatsSubSecondDuration() {
assertEquals("duration(0h, 0min, 0s, 999ms)", formatDuration(999L))
}
@Test
fun durationStringFormatsMinuteDuration() {
assertEquals("duration(0h, 1min, 1s, 0ms)", formatDuration(61_000L))
}
@Test
fun durationStringFormatsHourDuration() {
assertEquals("duration(1h, 1min, 1s, 0ms)", formatDuration(3_661_000L))
}
@Test
fun durationStringFormatsMixedDuration() {
assertEquals("duration(2h, 3min, 1s, 234ms)", formatDuration(7_381_234L))
}
private fun formatDuration(millis: Long): String {
val method = LogInterceptor::class.java.getDeclaredMethod("getDurationString", Long::class.javaPrimitiveType)
method.isAccessible = true
return method.invoke(LogInterceptor(), millis) as String
}
}
@@ -0,0 +1,296 @@
package eu.qsfera.android.lib.resources.files.tus
import android.accounts.Account
import android.accounts.AccountManager
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import eu.qsfera.android.lib.common.QSferaAccount
import eu.qsfera.android.lib.common.QSferaClient
import eu.qsfera.android.lib.common.accounts.AccountUtils
import eu.qsfera.android.lib.common.authentication.QSferaCredentialsFactory
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
import eu.qsfera.android.lib.resources.files.tus.CreateTusUploadRemoteOperation.Base64Encoder
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.Base64
@RunWith(RobolectricTestRunner::class)
class TusIntegrationTest {
private lateinit var server: MockWebServer
private val context by lazy { ApplicationProvider.getApplicationContext<android.content.Context>() }
private val accountType = "com.example"
private val userId = "user-123"
private val username = "user@example.com"
private val token = "TEST_TOKEN"
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
private fun newClient(): QSferaClient {
val base = server.url("/").toString().removeSuffix("/")
val am = AccountManager.get(context)
val account = Account("$username@${Uri.parse(base).host}", accountType)
am.addAccountExplicitly(account, null, null)
am.setUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL, base)
am.setUserData(account, AccountUtils.Constants.KEY_ID, userId)
val ocAccount = QSferaAccount(account, context)
val client = QSferaClient(ocAccount.baseUri, /*connectionValidator*/ null, /*sync*/ true, /*singleSession*/ null, context)
client.account = ocAccount
client.credentials = QSferaCredentialsFactory.newBearerCredentials(username, token)
return client
}
@Test
fun create_patch_head_delete_success() {
val client = newClient()
val collectionPath = "/remote.php/dav/uploads/$userId"
val locationPath = "$collectionPath/UPLD-123"
val localFile = File.createTempFile("tus", ".bin").apply {
writeBytes(byteArrayOf(1, 2, 3, 4, 5))
}
// 1) POST Create -> 201 + Location
server.enqueue(
MockResponse()
.setResponseCode(201)
.addHeader("Tus-Resumable", "1.0.0")
.addHeader("Location", locationPath)
)
// 2) PATCH -> 204 + Upload-Offset
server.enqueue(
MockResponse()
.setResponseCode(204)
.addHeader("Upload-Offset", "5")
)
// 3) HEAD -> 204 + Upload-Offset
server.enqueue(
MockResponse()
.setResponseCode(204)
.addHeader("Upload-Offset", "5")
)
// 4) DELETE -> 204
server.enqueue(
MockResponse()
.setResponseCode(204)
)
// Create
val create = CreateTusUploadRemoteOperation(
file = localFile,
remotePath = "/test.bin",
mimetype = "application/octet-stream",
metadata = mapOf("filename" to "test.bin"),
useCreationWithUpload = false,
firstChunkSize = null,
tusUrl = null,
collectionUrlOverride = server.url(collectionPath).toString(),
base64Encoder = object : Base64Encoder {
override fun encode(bytes: ByteArray): String =
Base64.getEncoder().encodeToString(bytes)
}
)
val createResult = create.execute(client)
if (!createResult.isSuccess) {
val msg = "DEBUG: Create operation failed. Code: ${createResult.code}, " +
"HttpCode: ${createResult.httpCode}, Exception: ${createResult.exception}"
throw RuntimeException(msg, createResult.exception)
}
assertTrue("Create operation failed", createResult.isSuccess)
val creationResult = createResult.data
assertNotNull(creationResult)
val absoluteLocation = creationResult!!.uploadUrl
val offset = creationResult.uploadOffset
println("absoluteLocation: $absoluteLocation")
println("locationPath: $locationPath")
println("endsWith: ${absoluteLocation.endsWith(locationPath)}")
assertTrue(absoluteLocation.endsWith(locationPath))
assertEquals(0L, offset)
// Verify POST request headers
val postReq = server.takeRequest()
assertEquals("POST", postReq.method)
assertEquals("Bearer $token", postReq.getHeader("Authorization"))
assertEquals("1.0.0", postReq.getHeader("Tus-Resumable"))
assertEquals("5", postReq.getHeader("Upload-Length"))
assertEquals(collectionPath, postReq.path)
// Patch
val patch = PatchTusUploadChunkRemoteOperation(
localPath = localFile.absolutePath,
uploadUrl = absoluteLocation,
offset = 0,
chunkSize = 5
)
val patchResult = patch.execute(client)
assertTrue(patchResult.isSuccess)
assertEquals(5L, patchResult.data)
// Verify PATCH request
val patchReq = server.takeRequest()
assertEquals("PATCH", patchReq.method)
assertEquals("Bearer $token", patchReq.getHeader("Authorization"))
assertEquals("1.0.0", patchReq.getHeader("Tus-Resumable"))
assertEquals("0", patchReq.getHeader("Upload-Offset"))
assertEquals("application/offset+octet-stream", patchReq.getHeader("Content-Type"))
assertEquals(Uri.parse(absoluteLocation).encodedPath, patchReq.path)
// Head
val head = GetTusUploadOffsetRemoteOperation(absoluteLocation)
val headResult = head.execute(client)
assertTrue(headResult.isSuccess)
assertEquals(5L, headResult.data)
val headReq = server.takeRequest()
assertEquals("HEAD", headReq.method)
assertEquals("Bearer $token", headReq.getHeader("Authorization"))
assertEquals("1.0.0", headReq.getHeader("Tus-Resumable"))
// Delete
val del = DeleteTusUploadRemoteOperation(absoluteLocation)
val delResult = del.execute(client)
assertTrue(delResult.isSuccess)
val delReq = server.takeRequest()
assertEquals("DELETE", delReq.method)
assertEquals("Bearer $token", delReq.getHeader("Authorization"))
assertEquals("1.0.0", delReq.getHeader("Tus-Resumable"))
}
@Test
fun creation_with_upload_returns_offset() {
val client = newClient()
val collectionPath = "/remote.php/dav/uploads/$userId"
val locationPath = "$collectionPath/UPLD-WITH-DATA"
val localFile = File.createTempFile("tus", ".bin").apply {
writeBytes(ByteArray(100) { it.toByte() })
}
val firstChunkSize = 50L
// POST Create with Upload -> 201 + Location + Upload-Offset
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (request.path == collectionPath) {
// Verify body content
val bodySize = request.bodySize
assertEquals(firstChunkSize, bodySize)
// Verify body bytes (first 50 bytes of the file)
val expectedBytes = ByteArray(firstChunkSize.toInt()) { it.toByte() }
val actualBytes = request.body.readByteArray()
assertArrayEquals(expectedBytes, actualBytes)
return MockResponse()
.setResponseCode(201)
.addHeader("Tus-Resumable", "1.0.0")
.addHeader("Location", locationPath)
.addHeader("Upload-Offset", bodySize.toString())
}
return MockResponse().setResponseCode(404)
}
}
val create = CreateTusUploadRemoteOperation(
file = localFile,
remotePath = "/test-with-data.bin",
mimetype = "application/octet-stream",
metadata = mapOf("filename" to "test-with-data.bin"),
useCreationWithUpload = true,
firstChunkSize = firstChunkSize,
tusUrl = null,
collectionUrlOverride = server.url(collectionPath).toString(),
base64Encoder = object : Base64Encoder {
override fun encode(bytes: ByteArray): String =
Base64.getEncoder().encodeToString(bytes)
}
)
val createResult = create.execute(client)
assertTrue("Create operation failed", createResult.isSuccess)
val creationResult = createResult.data
assertNotNull(creationResult)
assertEquals(firstChunkSize, creationResult!!.uploadOffset)
assertTrue(creationResult.uploadUrl.endsWith(locationPath))
// Verify POST request
val postReq = server.takeRequest()
assertEquals("POST", postReq.method)
assertEquals("Bearer $token", postReq.getHeader("Authorization"))
assertEquals("1.0.0", postReq.getHeader("Tus-Resumable"))
// creation-with-upload sends Content-Type and Content-Length for the chunk
assertEquals("application/offset+octet-stream", postReq.getHeader("Content-Type"))
assertEquals(firstChunkSize.toString(), postReq.getHeader("Content-Length"))
}
@Test
fun patch_wrong_offset_returns_conflict() {
val client = newClient()
val locationPath = "/remote.php/dav/uploads/$userId/UPLD-err"
// No need to POST; directly simulate an existing upload URL
// Server responds 412 to PATCH
server.enqueue(MockResponse().setResponseCode(412))
val tmp = File.createTempFile("tus", ".bin")
tmp.writeBytes(ByteArray(10) { 1 })
val patch = PatchTusUploadChunkRemoteOperation(
localPath = tmp.absolutePath,
uploadUrl = server.url(locationPath).toString(),
offset = 0,
chunkSize = 10
)
val res = patch.execute(client)
assertFalse(res.isSuccess)
assertEquals(RemoteOperationResult.ResultCode.CONFLICT, res.code)
val req = server.takeRequest()
assertEquals("PATCH", req.method)
assertEquals("Bearer $token", req.getHeader("Authorization"))
}
@Test
fun cancel_before_start_returns_cancelled() {
val client = newClient()
val locationPath = "/remote.php/dav/uploads/$userId/UPLD-cancel"
// No requests expected because we cancel before run
val tmp = File.createTempFile("tus", ".bin")
tmp.writeBytes(ByteArray(1024 * 64) { 1 })
val op = PatchTusUploadChunkRemoteOperation(
localPath = tmp.absolutePath,
uploadUrl = server.url(locationPath).toString(),
offset = 0,
chunkSize = 1024 * 64L
)
op.cancel()
val result = op.execute(client)
assertTrue(result.isCancelled)
}
}
@@ -0,0 +1,93 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2020 ownCloud GmbH.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
package eu.qsfera.android.lib.resources.shares.responses
import eu.qsfera.android.lib.resources.CommonOcsResponse
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.lang.reflect.Type
class ShareeResponseTest {
lateinit var adapter: JsonAdapter<CommonOcsResponse<ShareeOcsResponse>>
private fun loadResponses(fileName: String) =
adapter.fromJson(File(fileName).readText())
@Before
fun prepare() {
val moshi = Moshi.Builder().build()
val type: Type = Types.newParameterizedType(CommonOcsResponse::class.java, ShareeOcsResponse::class.java)
adapter = moshi.adapter(type)
}
@Test
fun `check structure - ok - contains meta`() {
val response = loadResponses(EXAMPLE_RESPONSE_JSON)!!
assertEquals("OK", response.ocs.meta.message!!)
assertEquals(200, response.ocs.meta.statusCode)
assertEquals("ok", response.ocs.meta.status)
assertTrue(response.ocs.meta.itemsPerPage?.isEmpty()!!)
assertTrue(response.ocs.meta.totalItems?.isEmpty()!!)
}
@Test
fun `example response - ok - correct sturcture`() {
val response = loadResponses(EXAMPLE_RESPONSE_JSON)!!
assertEquals(2, response.ocs.data?.groups?.size)
assertEquals(0, response.ocs.data?.remotes?.size)
assertEquals(2, response.ocs.data?.users?.size)
assertEquals(0, response.ocs.data?.exact?.groups?.size)
assertEquals(0, response.ocs.data?.exact?.remotes?.size)
assertEquals(1, response.ocs.data?.exact?.users?.size)
assertEquals("user1@user1.com", response.ocs.data?.users?.get(0)?.value?.additionalInfo)
assertNull(response.ocs.data?.users?.get(1)?.value?.additionalInfo)
}
@Test
fun `check empty response - ok - parsing ok`() {
val response = loadResponses(EMPTY_RESPONSE_JSON)!!
assertTrue(response.ocs.data?.exact?.groups?.isEmpty()!!)
assertTrue(response.ocs.data?.exact?.remotes?.isEmpty()!!)
assertTrue(response.ocs.data?.exact?.users?.isEmpty()!!)
assertTrue(response.ocs.data?.groups?.isEmpty()!!)
assertTrue(response.ocs.data?.remotes?.isEmpty()!!)
assertTrue(response.ocs.data?.users?.isEmpty()!!)
}
companion object {
private const val RESOURCES_PATH =
"src/test/responses/eu.qsfera.android.lib.resources.sharees.responses"
const val EXAMPLE_RESPONSE_JSON = "$RESOURCES_PATH/example_sharee_response.json"
const val EMPTY_RESPONSE_JSON = "$RESOURCES_PATH/empty_sharee_response.json"
}
}
@@ -0,0 +1,47 @@
package eu.qsfera.android.lib.resources.webfinger.responses
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import java.io.File
class WebFingerResponseTest {
lateinit var adapter: JsonAdapter<WebFingerResponse>
private fun loadResponses(fileName: String) = adapter.fromJson(File(fileName).readText())
@Before
fun prepare() {
val moshi = Moshi.Builder().build()
adapter = moshi.adapter(WebFingerResponse::class.java)
}
@Test
fun `check rel in too much information - ok`() {
val response = loadResponses(TOO_MUCH_INFORMATION_JSON)!!
Assert.assertEquals("https://gast.somedomain.de", response.links!![0].href)
Assert.assertEquals("http://webfinger.qsfera/rel/server-instance", response.links!![0].rel)
}
@Test(expected = JsonDataException::class)
fun `check key value pairs - ko - no href key`() {
val response = loadResponses(BROKEN_JSON)!!
Assert.assertEquals("https://gast.somedomain.de", response.links!![0].href)
}
@Test(expected = JsonDataException::class)
fun `check key value pairs - ko - no rel key`() {
val response = loadResponses(BROKEN_JSON)!!
Assert.assertEquals("https://gast.somedomain.de", response.links!![0].href)
}
companion object {
private const val RESOURCES_PATH =
"src/test/responses/eu.qsfera.android.lib.resources.webfinger.responses"
private const val TOO_MUCH_INFORMATION_JSON = "$RESOURCES_PATH/to_much_information_response.json"
private const val BROKEN_JSON = "$RESOURCES_PATH/broken_response.json"
}
}
@@ -0,0 +1,21 @@
{
"ocs": {
"meta": {
"status": "ok",
"statuscode": 100,
"message": "OK",
"totalitems": "",
"itemsperpage": ""
},
"data": {
"exact": {
"users": [],
"groups": [],
"remotes": []
},
"users": [],
"groups": [],
"remotes": []
}
}
}
@@ -0,0 +1,60 @@
{
"ocs": {
"data": {
"exact": {
"groups": [],
"remotes": [],
"users": [
{
"label": "admin",
"value": {
"shareType": 0,
"shareWith": "admin"
}
}
]
},
"groups": [
{
"label": "group1",
"value": {
"shareType": 1,
"shareWith": "group1"
}
},
{
"label": "group2",
"value": {
"shareType": 1,
"shareWith": "group2"
}
}
],
"remotes": [],
"users": [
{
"label": "user1",
"value": {
"shareType": 0,
"shareWith": "user1",
"shareWithAdditionalInfo": "user1@user1.com"
}
},
{
"label": "user2",
"value": {
"shareType": 0,
"shareWith": "user2"
}
}
]
},
"meta": {
"itemsperpage": "",
"message": "OK",
"status": "ok",
"statuscode": 200,
"totalitems": ""
}
}
}
@@ -0,0 +1,15 @@
{
"subject": "acct:bob@example.com",
"aliases": [
"https://www.example.com/~bob/"
],
"properties": {
"http://example.com/ns/role": "employee"
},
"links": [
{
"lel": "http://webfinger.example/rel/profile-page",
"foo": "https://www.example.com/~bob/"
}
]
}
@@ -0,0 +1,9 @@
{
"subject": "acct:peter.sine@gurken.xxx",
"links": [
{
"rel": "http://webfinger.example/rel/businesscard",
"href": "https://www.example.com/~bob/bob.vcf"
}
]
}
@@ -0,0 +1,9 @@
{
"links": [
{
"href": "https://gast.somedomain.de",
"rel": "http://webfinger.qsfera/rel/server-instance"
}
],
"subject": "acct:peter.sine@gurken.xxx"
}
@@ -0,0 +1,24 @@
{
"subject": "acct:peter.sine@gurken.xxx",
"aliases": [
"https://www.example.com/~bob/"
],
"properties": {
"http://example.com/ns/role": "employee"
},
"gurken": {
"whatever": 42
},
"links": [
{
"gurken": "sallat",
"href": "https://gast.somedomain.de",
"rel": "http://webfinger.qsfera/rel/server-instance"
},
{
"gurken": "sallat",
"rel": "http://webfinger.example/rel/businesscard",
"href": "https://www.example.com/~bob/bob.vcf"
}
]
}