diff --git a/build.gradle.kts b/build.gradle.kts index edb293b..6d8a660 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,9 +54,14 @@ tasks.withType().configureEach { } dependencies { - api(libs.okhttp) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.server.http.redirect) + implementation(libs.slf4j) + //api(libs.okhttp) api(libs.xpp3) testImplementation(libs.junit4) + testImplementation(libs.ktor.client.mock) testImplementation(libs.okhttp.mockwebserver) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da3ef7c..d134e7c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,12 +3,19 @@ dokka = "2.0.0" junit4 = "4.13.2" kotlin = "2.2.0" okhttpVersion = "5.1.0" +ktor = "3.2.1" +slf4j = "1.7.36" xpp3Version = "1.1.6" [libraries] junit4 = { module = "junit:junit", version.ref = "junit4" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-server-http-redirect = { module = "io.ktor:ktor-server-http-redirect", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" } -okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttpVersion" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttpVersion" } +slf4j = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" } xpp3 = { module = "org.ogce:xpp3", version.ref = "xpp3Version" } [plugins] diff --git a/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt b/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt deleted file mode 100644 index 44edc38..0000000 --- a/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt +++ /dev/null @@ -1,311 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ - -package at.bitfire.dav4jvm - -import okhttp3.Authenticator -import okhttp3.Challenge -import okhttp3.Credentials -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.Response -import okhttp3.Route -import okio.Buffer -import okio.ByteString.Companion.toByteString -import java.io.IOException -import java.util.LinkedList -import java.util.Locale -import java.util.UUID -import java.util.concurrent.atomic.AtomicInteger -import java.util.logging.Logger - -/** - * Handler to manage authentication against a given service (may be limited to one domain). - * There's no domain-based cache, because the same user name and password will be used for - * all requests. - * - * Authentication methods/credentials found to be working will be cached for further requests - * (this is why the interceptor is needed). - * - * Usage: Set as authenticator *and* as network interceptor. - */ -class BasicDigestAuthHandler( - /** Authenticate only against hosts ending with this domain (may be null, which means no restriction) */ - val domain: String?, - - val username: String, - val password: CharArray, - - val insecurePreemptive: Boolean = false -): Authenticator, Interceptor { - - companion object { - private const val HEADER_AUTHORIZATION = "Authorization" - - // cached digest parameters - var clientNonce = h(UUID.randomUUID().toString()) - var nonceCount = AtomicInteger(1) - - fun quotedString(s: String) = "\"" + s.replace("\"", "\\\"") + "\"" - fun h(data: String) = data.toByteArray().toByteString().md5().hex() - - fun h(body: RequestBody): String { - val buffer = Buffer() - body.writeTo(buffer) - return buffer.readByteArray().toByteString().md5().hex() - } - - fun kd(secret: String, data: String) = h("$secret:$data") - } - - // cached authentication schemes - private var basicAuth: Challenge? = null - private var digestAuth: Challenge? = null - - private val logger = Logger.getLogger(javaClass.name) - - - fun authenticateRequest(request: Request, response: Response?): Request? { - domain?.let { - val host = request.url.host - if (!domain.equals(UrlUtils.hostToDomain(host), true)) { - logger.warning("Not authenticating against $host because it doesn't belong to $domain") - return null - } - } - - if (response == null) { - // we're not processing a 401 response - - if (basicAuth == null && digestAuth == null && (request.isHttps || insecurePreemptive)) { - logger.fine("Trying Basic auth preemptively") - basicAuth = Challenge("Basic", "") - } - - } else { - // we're processing a 401 response - - var newBasicAuth: Challenge? = null - var newDigestAuth: Challenge? = null - for (challenge in response.challenges()) - when { - "Basic".equals(challenge.scheme, true) -> { - basicAuth?.let { - logger.warning("Basic credentials didn't work last time -> aborting") - basicAuth = null - return null - } - newBasicAuth = challenge - } - "Digest".equals(challenge.scheme, true) -> { - if (digestAuth != null && !"true".equals(challenge.authParams["stale"], true)) { - logger.warning("Digest credentials didn't work last time and server nonce has not expired -> aborting") - digestAuth = null - return null - } - newDigestAuth = challenge - } - } - - basicAuth = newBasicAuth - digestAuth = newDigestAuth - } - - // we MUST prefer Digest auth [https://tools.ietf.org/html/rfc2617#section-4.6] - when { - digestAuth != null -> { - logger.fine("Adding Digest authorization request for ${request.url}") - return digestRequest(request, digestAuth) - } - - basicAuth != null -> { - logger.fine("Adding Basic authorization header for ${request.url}") - - /* In RFC 2617 (obsolete), there was no encoding for credentials defined, although - one can interpret it as "use ISO-8859-1 encoding". This has been clarified by RFC 7617, - which creates a new charset parameter for WWW-Authenticate, which always must be UTF-8. - So, UTF-8 encoding for credentials is compatible with all RFC 7617 servers and many, - but not all pre-RFC 7617 servers. */ - return request.newBuilder() - .header(HEADER_AUTHORIZATION, Credentials.basic(username, password.concatToString(), Charsets.UTF_8)) - .build() - } - - response != null -> - logger.warning("No supported authentication scheme") - } - - return null - } - - fun digestRequest(request: Request, digest: Challenge?): Request? { - if (digest == null) - return null - - val realm = digest.authParams["realm"] - val opaque = digest.authParams["opaque"] - val nonce = digest.authParams["nonce"] - - val algorithm = Algorithm.determine(digest.authParams["algorithm"]) - val qop = Protection.selectFrom(digest.authParams["qop"]) - - // build response parameters - var response: String? = null - - val params = LinkedList() - params.add("username=${quotedString(username)}") - if (realm != null) - params.add("realm=${quotedString(realm)}") - else { - logger.warning("No realm provided, aborting Digest auth") - return null - } - if (nonce != null) - params.add("nonce=${quotedString(nonce)}") - else { - logger.warning("No nonce provided, aborting Digest auth") - return null - } - if (opaque != null) - params.add("opaque=${quotedString(opaque)}") - - if (algorithm != null) - params.add("algorithm=${quotedString(algorithm.algorithm)}") - - val method = request.method - val digestURI = request.url.encodedPath - params.add("uri=${quotedString(digestURI)}") - - if (qop != null) { - params.add("qop=${qop.qop}") - params.add("cnonce=${quotedString(clientNonce)}") - - val nc = nonceCount.getAndIncrement() - val ncValue = String.format(Locale.ROOT, "%08x", nc) - params.add("nc=$ncValue") - - val a1: String? = when (algorithm) { - Algorithm.MD5 -> - "$username:$realm:${password.concatToString()}" - Algorithm.MD5_SESSION -> - h("$username:$realm:${password.concatToString()}") + ":$nonce:$clientNonce" - else -> - null - } - // Contains password! Only uncomment for debugging: - // logger.finer("A1=$a1") - - val a2: String? = when (qop) { - Protection.Auth -> - "$method:$digestURI" - Protection.AuthInt -> { - try { - val body = request.body - "$method:$digestURI:" + (if (body != null) h(body) else h("")) - } catch(e: IOException) { - logger.warning("Couldn't get entity-body for hash calculation") - null - } - } - } - // logger.finer("A2=$a2") - - if (a1 != null && a2 != null) - response = kd(h(a1), "$nonce:$ncValue:$clientNonce:${qop.qop}:${h(a2)}") - - } else { - logger.finer("Using legacy Digest auth") - - // legacy (backwards compatibility with RFC 2069) - if (algorithm == Algorithm.MD5) { - val a1 = "$username:$realm:${password.concatToString()}" - val a2 = "$method:$digestURI" - response = kd(h(a1), nonce + ":" + h(a2)) - } - } - - return if (response != null) { - params.add("response=" + quotedString(response)) - request.newBuilder() - .header(HEADER_AUTHORIZATION, "Digest " + params.joinToString(", ")) - .build() - } else - null - } - - - private enum class Algorithm( - val algorithm: String - ) { - MD5("MD5"), - MD5_SESSION("MD5-sess"); - - companion object { - fun determine(paramValue: String?): Algorithm? { - return when { - paramValue == null || MD5.algorithm.equals(paramValue, true) -> - MD5 - MD5_SESSION.algorithm.equals(paramValue, true) -> - MD5_SESSION - else -> { - val logger = Logger.getLogger(Algorithm::javaClass.name) - logger.warning("Ignoring unknown hash algorithm: $paramValue") - null - } - } - } - } - } - - private enum class Protection( - val qop: String - ) { // quality of protection: - Auth("auth"), // authentication only - AuthInt("auth-int"); // authentication with integrity protection - - companion object { - fun selectFrom(paramValue: String?): Protection? { - paramValue?.let { - var qopAuth = false - var qopAuthInt = false - for (qop in paramValue.split(",")) - when (qop) { - "auth" -> qopAuth = true - "auth-int" -> qopAuthInt = true - } - - // prefer auth-int as it provides more protection - if (qopAuthInt) - return AuthInt - else if (qopAuth) - return Auth - } - return null - } - } - } - - - override fun authenticate(route: Route?, response: Response) = - authenticateRequest(response.request, response) - - override fun intercept(chain: Interceptor.Chain): Response { - var request = chain.request() - if (request.header(HEADER_AUTHORIZATION) == null) { - // try to apply cached authentication - val authRequest = authenticateRequest(request, null) - if (authRequest != null) - request = authRequest - } - return chain.proceed(request) - } - -} \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt b/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt index 68ba2cb..a25eac8 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt @@ -10,11 +10,13 @@ package at.bitfire.dav4jvm +import io.ktor.client.statement.HttpResponse + /** * Callback for the OPTIONS request. */ fun interface CapabilitiesCallback { - fun onCapabilities(davCapabilities: Set, response: okhttp3.Response) + fun onCapabilities(davCapabilities: Set, response: HttpResponse) } /** @@ -42,5 +44,5 @@ fun interface ResponseCallback { * Called for a HTTP response. Typically this is only called for successful/redirect * responses because HTTP errors throw an exception before this callback is called. */ - fun onResponse(response: okhttp3.Response) + fun onResponse(response: HttpResponse) } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt b/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt index 9042257..880d1e9 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt @@ -18,26 +18,30 @@ import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV import at.bitfire.dav4jvm.property.webdav.GetContentType import at.bitfire.dav4jvm.property.webdav.GetETag import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV -import okhttp3.HttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody +import io.ktor.client.HttpClient +import io.ktor.client.request.prepareRequest +import io.ktor.client.request.setBody +import io.ktor.client.request.url +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.Url +import io.ktor.util.logging.Logger +import org.slf4j.LoggerFactory import java.io.IOException import java.io.StringWriter -import java.util.logging.Logger @Suppress("unused") class DavAddressBook @JvmOverloads constructor( - httpClient: OkHttpClient, - location: HttpUrl, - logger: Logger = Logger.getLogger(DavAddressBook::javaClass.name) + httpClient: HttpClient, + location: Url, + logger: Logger = LoggerFactory.getLogger(DavAddressBook::javaClass.name) ): DavCollection(httpClient, location, logger) { companion object { - val MIME_JCARD = "application/vcard+json".toMediaType() - val MIME_VCARD3_UTF8 = "text/vcard;charset=utf-8".toMediaType() - val MIME_VCARD4 = "text/vcard;version=4.0".toMediaType() + val MIME_JCARD = ContentType.parse("application/vcard+json") + val MIME_VCARD3_UTF8 = ContentType.parse("text/vcard;charset=utf-8") + val MIME_VCARD4 = ContentType.parse("text/vcard;version=4.0") val ADDRESSBOOK_QUERY = Property.Name(NS_CARDDAV, "addressbook-query") val ADDRESSBOOK_MULTIGET = Property.Name(NS_CARDDAV, "addressbook-multiget") @@ -56,7 +60,7 @@ class DavAddressBook @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on WebDAV error */ - fun addressbookQuery(callback: MultiResponseCallback): List { + suspend fun addressbookQuery(callback: MultiResponseCallback): List { /* @@ -77,12 +81,14 @@ class DavAddressBook @JvmOverloads constructor( serializer.endDocument() followRedirects { - httpClient.newCall(Request.Builder() - .url(location) - .method("REPORT", writer.toString().toRequestBody(MIME_XML)) - .header("Depth", "1") - .build()).execute() - }.use { response -> + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("REPORT") + headers.append(HttpHeaders.ContentType, MIME_XML.toString()) + setBody(writer.toString()) + headers.append(HttpHeaders.Depth, "1") + }.execute() + }.let { response -> return processMultiStatus(response, callback) } } @@ -104,7 +110,7 @@ class DavAddressBook @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on WebDAV error */ - fun multiget(urls: List, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List { + suspend fun multiget(urls: List, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List { /* + return processMultiStatus(response, callback) } } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt b/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt index 7a8a176..313414a 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt @@ -19,11 +19,16 @@ import at.bitfire.dav4jvm.property.caldav.ScheduleTag import at.bitfire.dav4jvm.property.webdav.GetContentType import at.bitfire.dav4jvm.property.webdav.GetETag import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV -import okhttp3.HttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody +import io.ktor.client.HttpClient +import io.ktor.client.request.prepareRequest +import io.ktor.client.request.setBody +import io.ktor.client.request.url +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.Url +import io.ktor.util.logging.Logger +import org.slf4j.LoggerFactory import java.io.IOException import java.io.StringWriter import java.time.Instant @@ -31,18 +36,17 @@ import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.Locale -import java.util.logging.Logger @Suppress("unused") class DavCalendar @JvmOverloads constructor( - httpClient: OkHttpClient, - location: HttpUrl, - logger: Logger = Logger.getLogger(DavCalendar::javaClass.name) + httpClient: HttpClient, + location: Url, + logger: Logger = LoggerFactory.getLogger(DavCalendar::javaClass.name) ): DavCollection(httpClient, location, logger) { companion object { - val MIME_ICALENDAR = "text/calendar".toMediaType() - val MIME_ICALENDAR_UTF8 = "text/calendar;charset=utf-8".toMediaType() + val MIME_ICALENDAR = ContentType.parse("text/calendar") + val MIME_ICALENDAR_UTF8 = ContentType.parse("text/calendar;charset=utf-8") val CALENDAR_QUERY = Property.Name(NS_CALDAV, "calendar-query") val CALENDAR_MULTIGET = Property.Name(NS_CALDAV, "calendar-multiget") @@ -73,7 +77,7 @@ class DavCalendar @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on WebDAV error */ - fun calendarQuery(component: String, start: Instant?, end: Instant?, callback: MultiResponseCallback): List { + suspend fun calendarQuery(component: String, start: Instant?, end: Instant?, callback: MultiResponseCallback): List { /* @@ -119,13 +123,15 @@ class DavCalendar @JvmOverloads constructor( serializer.endDocument() followRedirects { - httpClient.newCall(Request.Builder() - .url(location) - .method("REPORT", writer.toString().toRequestBody(MIME_XML)) - .header("Depth", "1") - .build()).execute() - }.use { - return processMultiStatus(it, callback) + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("REPORT") + setBody(writer.toString()) + headers.append(HttpHeaders.ContentType, MIME_XML.toString()) + headers.append(HttpHeaders.Depth, "1") + }.execute() + }.let { response -> + return processMultiStatus(response, callback) } } @@ -146,7 +152,7 @@ class DavCalendar @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on WebDAV error */ - fun multiget(urls: List, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List { + suspend fun multiget(urls: List, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List { /* @@ -177,12 +183,14 @@ class DavCalendar @JvmOverloads constructor( serializer.endDocument() followRedirects { - httpClient.newCall(Request.Builder() - .url(location) - .method("REPORT", writer.toString().toRequestBody(MIME_XML)) - .build()).execute() - }.use { - return processMultiStatus(it, callback) + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("REPORT") + setBody(writer.toString()) + headers.append(HttpHeaders.ContentType, MIME_XML.toString()) + }.execute() + }.let { response -> + return processMultiStatus(response, callback) } } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt b/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt index b2407dc..3eb5a2b 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt @@ -15,20 +15,25 @@ import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV import at.bitfire.dav4jvm.property.webdav.SyncToken -import okhttp3.HttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.prepareRequest +import io.ktor.client.request.setBody +import io.ktor.client.request.url +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.Url +import io.ktor.util.logging.Logger +import org.slf4j.LoggerFactory import java.io.StringWriter -import java.util.logging.Logger /** * Represents a WebDAV collection. */ open class DavCollection @JvmOverloads constructor( - httpClient: OkHttpClient, - location: HttpUrl, - logger: Logger = Logger.getLogger(DavCollection::class.java.name) + httpClient: HttpClient, + location: Url, + logger: Logger = LoggerFactory.getLogger(DavCollection::class.java.name) ): DavResource(httpClient, location, logger) { companion object { @@ -54,7 +59,7 @@ open class DavCollection @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on WebDAV error */ - fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name, callback: MultiResponseCallback): List { + suspend fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name, callback: MultiResponseCallback): List { /* @@ -92,14 +97,15 @@ open class DavCollection @JvmOverloads constructor( serializer.endDocument() followRedirects { - httpClient.newCall(Request.Builder() - .url(location) - .method("REPORT", writer.toString().toRequestBody(MIME_XML)) - .header("Depth", "0") - .build()).execute() - }.use { - return processMultiStatus(it, callback) + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("REPORT") + setBody(writer.toString()) + header(HttpHeaders.ContentType, MIME_XML) + header(HttpHeaders.Depth, "0") + }.execute() + }.let { response -> + return processMultiStatus(response, callback) } } - } \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt b/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt index 25a94bb..220aa70 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt @@ -25,23 +25,47 @@ import at.bitfire.dav4jvm.property.caldav.NS_CALDAV import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV import at.bitfire.dav4jvm.property.webdav.SyncToken -import okhttp3.Headers -import okhttp3.HttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.HttpRequest +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.header +import io.ktor.client.request.prepareRequest +import io.ktor.client.request.setBody +import io.ktor.client.request.url +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsBytes +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.client.statement.readRawBytes +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HeadersBuilder +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.append +import io.ktor.http.cio.Request +import io.ktor.http.contentLength +import io.ktor.http.contentType +import io.ktor.http.headers +import io.ktor.http.isSecure +import io.ktor.http.takeFrom +import io.ktor.http.withCharset +import io.ktor.util.appendAll +import io.ktor.util.logging.Logger +import io.ktor.utils.io.core.readFully +import io.ktor.utils.io.jvm.javaio.toInputStream +import io.ktor.utils.io.readBuffer +import org.slf4j.LoggerFactory import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException import java.io.EOFException import java.io.IOException import java.io.Reader import java.io.StringWriter -import java.net.HttpURLConnection -import java.util.logging.Level -import java.util.logging.Logger import at.bitfire.dav4jvm.Response as DavResponse /** @@ -55,21 +79,20 @@ import at.bitfire.dav4jvm.Response as DavResponse * To cancel a request, interrupt the thread. This will cause the requests to * throw `InterruptedException` or `InterruptedIOException`. * - * @param httpClient [OkHttpClient] to access this object (must not follow redirects) + * @param httpClient [HttpClient] to access this object (must not follow redirects) * @param location location of the WebDAV resource * @param logger will be used for logging */ open class DavResource @JvmOverloads constructor( - val httpClient: OkHttpClient, - location: HttpUrl, - val logger: Logger = Logger.getLogger(DavResource::class.java.name) + val httpClient: HttpClient, + location: Url, + val logger: Logger = LoggerFactory.getLogger(DavResource::class.java.name) ) { companion object { const val MAX_REDIRECTS = 5 - const val HTTP_MULTISTATUS = 207 - val MIME_XML = "application/xml; charset=utf-8".toMediaType() + val MIME_XML = ContentType.Application.Xml.withCharset(Charsets.UTF_8) val PROPFIND = Property.Name(NS_WEBDAV, "propfind") val PROPERTYUPDATE = Property.Name(NS_WEBDAV, "propertyupdate") @@ -129,14 +152,15 @@ open class DavResource @JvmOverloads constructor( /** * URL of this resource (changes when being redirected by server) */ - var location: HttpUrl + var location: Url private set // allow internal modification only (for redirects) init { // Don't follow redirects (only useful for GET/POST). // This means we have to handle 30x responses ourselves. - require(!httpClient.followRedirects) { "httpClient must not follow redirects automatically" } + //TODO: Restore the require(!httpClient.followRedirects) part here somehow! + //require(!httpClient.followRedirects) { "httpClient must not follow redirects automatically" } this.location = location } @@ -161,26 +185,24 @@ open class DavResource @JvmOverloads constructor( * @throws DavException on HTTPS -> HTTP redirect */ @Throws(IOException::class, HttpException::class) - fun options(followRedirects: Boolean = false, callback: CapabilitiesCallback) { - val requestOptions = { - httpClient.newCall(Request.Builder() - .method("OPTIONS", null) - .header("Content-Length", "0") - .url(location) - .header("Accept-Encoding", "identity") // disable compression - .build()).execute() + suspend fun options(followRedirects: Boolean = false, callback: CapabilitiesCallback) { + val request = httpClient.prepareRequest { + url(location) + method = HttpMethod.Options + headers.append(HttpHeaders.ContentLength, "0") + headers.append(HttpHeaders.AcceptEncoding, "identity") } val response = if (followRedirects) - followRedirects(requestOptions) + followRedirects { request.execute() } else - requestOptions() - response.use { - checkStatus(response) - callback.onCapabilities( - HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet(), - response - ) - } + request.execute() + + checkStatus(response) + callback.onCapabilities( + HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet(), + response + ) + } /** @@ -195,32 +217,28 @@ open class DavResource @JvmOverloads constructor( * @throws DavException on WebDAV error or HTTPS -> HTTP redirect */ @Throws(IOException::class, HttpException::class, DavException::class) - fun move(destination: HttpUrl, overwrite: Boolean, callback: ResponseCallback) { - val requestBuilder = Request.Builder() - .method("MOVE", null) - .header("Content-Length", "0") - .header("Destination", destination.toString()) - - if (!overwrite) // RFC 4918 9.9.3 and 10.6, default value: T - requestBuilder.header("Overwrite", "F") + suspend fun move(destination: Url, overwrite: Boolean, callback: ResponseCallback) { followRedirects { - requestBuilder.url(location) - httpClient.newCall(requestBuilder - .build()) - .execute() - }.use { response -> + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("MOVE") + headers.append(HttpHeaders.ContentLength, "0") + headers.append(HttpHeaders.Destination, destination.toString()) + if (!overwrite) // RFC 4918 9.9.3 and 10.6, default value: T + headers.append(HttpHeaders.Overwrite, "F") + }.execute() + }.let { response -> checkStatus(response) - if (response.code == HTTP_MULTISTATUS) - /* Multiple resources were to be affected by the MOVE, but errors on some - of them prevented the operation from taking place. - [_] (RFC 4918 9.9.4. Status Codes for MOVE Method) */ + if (response.status == HttpStatusCode.MultiStatus) + /* Multiple resources were to be affected by the MOVE, but errors on some + of them prevented the operation from taking place. + [_] (RFC 4918 9.9.4. Status Codes for MOVE Method) */ throw HttpException(response) // update location - location.resolve(response.header("Location") ?: destination.toString())?.let { - location = it - } + val nPath = response.headers[HttpHeaders.Location] ?: destination.toString() + location = URLBuilder(location).takeFrom(nPath).build() callback.onResponse(response) } @@ -237,27 +255,23 @@ open class DavResource @JvmOverloads constructor( * @throws DavException on WebDAV error or HTTPS -> HTTP redirect */ @Throws(IOException::class, HttpException::class, DavException::class) - fun copy(destination:HttpUrl, overwrite: Boolean, callback: ResponseCallback) { - val requestBuilder = Request.Builder() - .method("COPY", null) - .header("Content-Length", "0") - .header("Destination", destination.toString()) - - if (!overwrite) // RFC 4918 9.9.3 and 10.6, default value: T - requestBuilder.header("Overwrite", "F") + suspend fun copy(destination: Url, overwrite: Boolean, callback: ResponseCallback) { followRedirects { - requestBuilder.url(location) - httpClient.newCall(requestBuilder - .build()) - .execute() - }.use{ response -> + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("COPY") + headers.append(HttpHeaders.ContentLength, "0") + headers.append(HttpHeaders.Destination, destination.toString()) + if (!overwrite) // RFC 4918 9.9.3 and 10.6, default value: T + headers.append("Overwrite", "F") + }.execute() + }.let { response -> checkStatus(response) - - if (response.code == HTTP_MULTISTATUS) - /* Multiple resources were to be affected by the COPY, but errors on some - of them prevented the operation from taking place. - [_] (RFC 4918 9.8.5. Status Codes for COPY Method) */ + if(response.status == HttpStatusCode.MultiStatus) + /* Multiple resources were to be affected by the COPY, but errors on some + of them prevented the operation from taking place. + [_] (RFC 4918 9.8.5. Status Codes for COPY Method) */ throw HttpException(response) callback.onResponse(response) @@ -279,19 +293,18 @@ open class DavResource @JvmOverloads constructor( * @throws DavException on HTTPS -> HTTP redirect */ @Throws(IOException::class, HttpException::class) - fun mkCol(xmlBody: String?, method: String = "MKCOL", headers: Headers? = null, callback: ResponseCallback) { - val rqBody = xmlBody?.toRequestBody(MIME_XML) - - val request = Request.Builder() - .method(method, rqBody) - .url(UrlUtils.withTrailingSlash(location)) - - if (headers != null) - request.headers(headers) + suspend fun mkCol(xmlBody: String?, method: String = "MKCOL", headersOptional: Headers? = null, callback: ResponseCallback) { followRedirects { - httpClient.newCall(request.build()).execute() - }.use { response -> + httpClient.prepareRequest { + this.method = HttpMethod.parse(method) + setBody(xmlBody) + headers.append(HttpHeaders.ContentType, MIME_XML) + url(UrlUtils.withTrailingSlash(location)) + if (headersOptional != null) + headers.appendAll(headersOptional) + }.execute() + }.let { response -> checkStatus(response) callback.onResponse(response) } @@ -308,7 +321,19 @@ open class DavResource @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on HTTPS -> HTTP redirect */ - fun head(callback: ResponseCallback) { + suspend fun head(callback: ResponseCallback) { + + followRedirects { + httpClient.prepareRequest { + method = HttpMethod.Head + url(location) + }.execute() + }.let { response -> + checkStatus(response) + callback.onResponse(response) + } + + /* TODO @ricki, second call omitted, not sure if this was done like that on purpose? followRedirects { httpClient.newCall( Request.Builder() @@ -320,6 +345,7 @@ open class DavResource @JvmOverloads constructor( checkStatus(response) callback.onResponse(response) } + */ } /** @@ -331,26 +357,23 @@ open class DavResource @JvmOverloads constructor( * @param accept value of `Accept` header (always sent for clarity; use */* if you don't care) * @param headers additional headers to send with the request * - * @return okhttp Response – **caller is responsible for closing it!** + * @return HttpResponse * * @throws IOException on I/O error * @throws HttpException on HTTP error * @throws DavException on HTTPS -> HTTP redirect */ - fun get(accept: String, headers: Headers?): Response = - followRedirects { - val request = Request.Builder() - .get() - .url(location) - + suspend fun get(accept: String, headers: Headers?): HttpResponse = + followRedirects { + httpClient.prepareRequest { + method = HttpMethod.Get + url(location) if (headers != null) - request.headers(headers) - - // always Accept header - request.header("Accept", accept) + this.headers.appendAll(headers) + header(HttpHeaders.Accept, accept) + }.execute() + } - httpClient.newCall(request.build()).execute() - } /** * Sends a GET request to the resource. Sends `Accept-Encoding: identity` to disable @@ -367,9 +390,8 @@ open class DavResource @JvmOverloads constructor( */ @Deprecated("Use get(accept, headers, callback) with explicit Accept-Encoding instead") @Throws(IOException::class, HttpException::class) - fun get(accept: String, callback: ResponseCallback) { - get(accept, Headers.headersOf("Accept-Encoding", "identity"), callback) - } + suspend fun get(accept: String, callback: ResponseCallback) = + get(accept, Headers.build { append(HttpHeaders.AcceptEncoding, "identity") }, callback) /** * Sends a GET request to the resource. Follows up to [MAX_REDIRECTS] redirects. @@ -385,8 +407,8 @@ open class DavResource @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on HTTPS -> HTTP redirect */ - fun get(accept: String, headers: Headers?, callback: ResponseCallback) { - get(accept, headers).use { response -> + suspend fun get(accept: String, headers: Headers?, callback: ResponseCallback) { + get(accept, headers).let { response -> checkStatus(response) callback.onResponse(response) } @@ -409,22 +431,18 @@ open class DavResource @JvmOverloads constructor( * @throws DavException on high-level errors */ @Throws(IOException::class, HttpException::class) - fun getRange(accept: String, offset: Long, size: Int, headers: Headers? = null, callback: ResponseCallback) { + suspend fun getRange(accept: String, offset: Long, size: Int, headers: Headers? = null, callback: ResponseCallback) { followRedirects { - val request = Request.Builder() - .get() - .url(location) - - if (headers != null) - request.headers(headers) - - val lastIndex = offset + size - 1 - request - .header("Accept", accept) - .header("Range", "bytes=$offset-$lastIndex") - - httpClient.newCall(request.build()).execute() - }.use { response -> + httpClient.prepareRequest { + method = HttpMethod.Get + url(location) + if (headers != null) + this.headers.appendAll(headers) + val lastIndex = offset + size - 1 + this.headers.append(HttpHeaders.Accept, accept) + this.headers.append(HttpHeaders.Range, "bytes=$offset-$lastIndex") + }.execute() + }.let { response -> checkStatus(response) callback.onResponse(response) } @@ -434,21 +452,19 @@ open class DavResource @JvmOverloads constructor( * Sends a GET request to the resource. Follows up to [MAX_REDIRECTS] redirects. */ @Throws(IOException::class, HttpException::class) - fun post(body: RequestBody, ifNoneMatch: Boolean = false, headers: Headers? = null, callback: ResponseCallback) { - followRedirects { - val builder = Request.Builder() - .post(body) - .url(location) - - if (ifNoneMatch) - // don't overwrite anything existing - builder.header("If-None-Match", "*") + suspend fun post(body: String, ifNoneMatch: Boolean = false, headers: Headers? = null, callback: ResponseCallback) { - if (headers != null) - builder.headers(headers) - - httpClient.newCall(builder.build()).execute() - }.use { response -> + followRedirects { + httpClient.prepareRequest { + method = HttpMethod.Post + url(location) + this.setBody(body) // TODO: check in detail if this is correct, changed to String instead of HttpRequest + if (ifNoneMatch) + this.headers.append(HttpHeaders.IfNoneMatch, "*") + if (headers?.isEmpty() == false) + this.headers.appendAll(headers) + }.execute() + }.let { response -> checkStatus(response) callback.onResponse(response) } @@ -471,35 +487,33 @@ open class DavResource @JvmOverloads constructor( * @throws DavException on HTTPS -> HTTP redirect */ @Throws(IOException::class, HttpException::class) - fun put( - body: RequestBody, + suspend fun put( + body: String, // TODO: Changed to String, maybe a problem for DAVx5? The ContentType is anyway defined in headers + headers: Headers = HeadersBuilder().build(), ifETag: String? = null, ifScheduleTag: String? = null, ifNoneMatch: Boolean = false, - headers: Map = emptyMap(), callback: ResponseCallback ) { - followRedirects { - val builder = Request.Builder() - .put(body) - .url(location) - if (ifETag != null) + followRedirects { + httpClient.prepareRequest { + method = HttpMethod.Put + //header(HttpHeaders.ContentType, contentType) + setBody(body) + url(location) + if (ifETag != null) // only overwrite specific version - builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) - if (ifScheduleTag != null) + header(HttpHeaders.IfMatch, QuotedStringUtils.asQuotedString(ifETag)) + if (ifScheduleTag != null) // only overwrite specific version - builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) - if (ifNoneMatch) + header(HttpHeaders.IfScheduleTagMatch, QuotedStringUtils.asQuotedString(ifScheduleTag)) + if (ifNoneMatch) // don't overwrite anything existing - builder.header("If-None-Match", "*") - - // Add custom headers - for ((key, value) in headers) - builder.header(key, value) - - httpClient.newCall(builder.build()).execute() - }.use { response -> + header(HttpHeaders.IfNoneMatch, "*") + // TODO: Check with Ricki: Is it okay to use just header here? Or should we use append? + }.execute() + }.let { response -> checkStatus(response) callback.onResponse(response) } @@ -522,33 +536,29 @@ open class DavResource @JvmOverloads constructor( * @throws DavException on HTTPS -> HTTP redirect */ @Throws(IOException::class, HttpException::class) - fun delete( + suspend fun delete( ifETag: String? = null, ifScheduleTag: String? = null, headers: Map = emptyMap(), callback: ResponseCallback ) { followRedirects { - val builder = Request.Builder() - .delete() - .url(location) - if (ifETag != null) - builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) - if (ifScheduleTag != null) - builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) - - // Add custom headers - for ((key, value) in headers) - builder.header(key, value) - - httpClient.newCall(builder.build()).execute() - }.use { response -> + httpClient.prepareRequest { + method = HttpMethod.Delete + url(location) + if (ifETag != null) + header(HttpHeaders.IfMatch, QuotedStringUtils.asQuotedString(ifETag)) + if (ifScheduleTag != null) + header(HttpHeaders.IfScheduleTagMatch, QuotedStringUtils.asQuotedString(ifScheduleTag)) + this.headers.appendAll(headers) // TODO: check with Ricki if the previous two headers also shouldn't be appended! + }.execute() + }.let { response -> checkStatus(response) - if (response.code == HTTP_MULTISTATUS) - /* If an error occurs deleting a member resource (a resource other than - the resource identified in the Request-URI), then the response can be - a 207 (Multi-Status). […] (RFC 4918 9.6.1. DELETE for Collections) */ + if (response.status == HttpStatusCode.MultiStatus) + /* If an error occurs deleting a member resource (a resource other than + the resource identified in the Request-URI), then the response can be + a 207 (Multi-Status). […] (RFC 4918 9.6.1. DELETE for Collections) */ throw HttpException(response) callback.onResponse(response) @@ -569,7 +579,7 @@ open class DavResource @JvmOverloads constructor( * @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect */ @Throws(IOException::class, HttpException::class, DavException::class) - fun propfind(depth: Int, vararg reqProp: Property.Name, callback: MultiResponseCallback) { + suspend fun propfind(depth: Int, vararg reqProp: Property.Name, callback: MultiResponseCallback) { // build XML request body val serializer = XmlUtils.newSerializer() val writer = StringWriter() @@ -587,13 +597,15 @@ open class DavResource @JvmOverloads constructor( serializer.endDocument() followRedirects { - httpClient.newCall(Request.Builder() - .url(location) - .method("PROPFIND", writer.toString().toRequestBody(MIME_XML)) - .header("Depth", if (depth >= 0) depth.toString() else "infinity") - .build()).execute() - }.use { - processMultiStatus(it, callback) + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("PROPFIND") + setBody(writer.toString()) + header(HttpHeaders.ContentType, MIME_XML) + header(HttpHeaders.Depth, if (depth >= 0) depth.toString() else "infinity") + }.execute() + }.let { response -> + processMultiStatus(response, callback) } } @@ -613,25 +625,25 @@ open class DavResource @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect */ - fun proppatch( + suspend fun proppatch( setProperties: Map, removeProperties: List, callback: MultiResponseCallback ) { - followRedirects { - val rqBody = createProppatchXml(setProperties, removeProperties) + val rqBody = createProppatchXml(setProperties, removeProperties) - httpClient.newCall( - Request.Builder() - .url(location) - .method("PROPPATCH", rqBody.toRequestBody(MIME_XML)) - .build() - ).execute() - }.use { + followRedirects { + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("PROPPATCH") + setBody(rqBody) + header(HttpHeaders.ContentType, MIME_XML) + }.execute() + }.let { response -> // TODO handle not only 207 Multi-Status // http://www.webdav.org/specs/rfc4918.html#PROPPATCH-status - processMultiStatus(it, callback) + processMultiStatus(response, callback) } } @@ -647,14 +659,16 @@ open class DavResource @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect */ - fun search(search: String, callback: MultiResponseCallback) { + suspend fun search(search: String, callback: MultiResponseCallback) { followRedirects { - httpClient.newCall(Request.Builder() - .url(location) - .method("SEARCH", search.toRequestBody(MIME_XML)) - .build()).execute() - }.use { - processMultiStatus(it, callback) + httpClient.prepareRequest { + url(location) + method = HttpMethod.parse("SEARCH") + setBody(search) + header(HttpHeaders.ContentType, MIME_XML) + }.execute() + }.let { response -> + processMultiStatus(response, callback) } } @@ -666,34 +680,34 @@ open class DavResource @JvmOverloads constructor( * * @throws HttpException in case of an HTTP error */ - protected fun checkStatus(response: Response) = - checkStatus(response.code, response.message, response) + protected fun checkStatus(response: HttpResponse) = + checkStatus(response.status, response.status.description, response) // TODO not sure if response.status.description still makes sense here. If no, the whole method can be removed. TODO: Check with Ricki /** * Checks the status from an HTTP response and throws an exception in case of an error. * * @throws HttpException (with XML error names, if available) in case of an HTTP error */ - private fun checkStatus(code: Int, message: String?, response: Response?) { - if (code / 100 == 2) + private fun checkStatus(httpStatusCode: HttpStatusCode, message: String?, response: HttpResponse?) { + if (httpStatusCode.value / 100 == 2) // everything OK return - throw when (code) { - HttpURLConnection.HTTP_UNAUTHORIZED -> + throw when (httpStatusCode) { + HttpStatusCode.Unauthorized -> if (response != null) UnauthorizedException(response) else UnauthorizedException(message) - HttpURLConnection.HTTP_FORBIDDEN -> + HttpStatusCode.Forbidden -> if (response != null) ForbiddenException(response) else ForbiddenException(message) - HttpURLConnection.HTTP_NOT_FOUND -> + HttpStatusCode.NotFound -> if (response != null) NotFoundException(response) else NotFoundException(message) - HttpURLConnection.HTTP_CONFLICT -> + HttpStatusCode.Conflict -> if (response != null) ConflictException(response) else ConflictException(message) - HttpURLConnection.HTTP_PRECON_FAILED -> + HttpStatusCode.PreconditionFailed -> if (response != null) PreconditionFailedException(response) else PreconditionFailedException(message) - HttpURLConnection.HTTP_UNAVAILABLE -> + HttpStatusCode.ServiceUnavailable -> if (response != null) ServiceUnavailableException(response) else ServiceUnavailableException(message) else -> - if (response != null) HttpException(response) else HttpException(code, message) + if (response != null) HttpException(response) else HttpException(httpStatusCode.value, message) } } @@ -706,18 +720,28 @@ open class DavResource @JvmOverloads constructor( * * @throws DavException on HTTPS -> HTTP redirect */ - internal fun followRedirects(sendRequest: () -> Response): Response { - lateinit var response: Response + internal suspend fun followRedirects(sendRequest: suspend () -> HttpResponse): HttpResponse { + + lateinit var response: HttpResponse for (attempt in 1..MAX_REDIRECTS) { response = sendRequest() - if (response.isRedirect) + if (response.status in listOf( + HttpStatusCode.PermanentRedirect, + HttpStatusCode.TemporaryRedirect, + HttpStatusCode.MultipleChoices, + HttpStatusCode.MovedPermanently, + HttpStatusCode.Found, + HttpStatusCode.SeeOther) + ) //if is redirect, based on okhttp3/Response.kt: HTTP_PERM_REDIRECT, HTTP_TEMP_REDIRECT, HTTP_MULT_CHOICE, HTTP_MOVED_PERM, HTTP_MOVED_TEMP, HTTP_SEE_OTHER // handle 3xx Redirection - response.use { - val target = it.header("Location")?.let { location.resolve(it) } + response.let { + val target = it.headers[HttpHeaders.Location]?.let { newLocation -> + URLBuilder(location).takeFrom(newLocation).build() + } if (target != null) { - logger.fine("Redirected, new location = $target") + logger.info("Redirected, new location = $target") // TODO: Is logger.info ok here? - if (location.isHttps && !target.isHttps) + if (location.protocol.isSecure() && !target.protocol.isSecure()) throw DavException("Received redirect from HTTPS to HTTP") location = target @@ -737,33 +761,35 @@ open class DavResource @JvmOverloads constructor( * * @throws DavException if the response is not a Multi-Status response */ - fun assertMultiStatus(response: Response) { - if (response.code != HTTP_MULTISTATUS) - throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", httpResponse = response) - - response.peekBody(XML_SIGNATURE.size.toLong()).use { body -> - body.contentType()?.let { mimeType -> - if (((mimeType.type != "application" && mimeType.type != "text")) || mimeType.subtype != "xml") { - /* Content-Type is not application/xml or text/xml although that is expected here. - Some broken servers return an XML response with some other MIME type. So we try to see - whether the response is maybe XML although the Content-Type is something else. */ - try { - response.peekBody(XML_SIGNATURE.size.toLong()).use { body -> - if (XML_SIGNATURE.contentEquals(body.bytes())) { - logger.warning("Received 207 Multi-Status that seems to be XML but has MIME type $mimeType") - - // response is OK, return and do not throw Exception below - return - } - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't scan for XML signature", e) + suspend fun assertMultiStatus(httpResponse: HttpResponse) { + val response = httpResponse + + if (response.status != HttpStatusCode.MultiStatus) + throw DavException("Expected 207 Multi-Status, got ${response.status.value} ${response.status.description}", httpResponse = response) + + val bodyChannel = response.bodyAsChannel() + + response.contentType()?.let { mimeType -> // is response.contentType() ok here? Or must it be the content type of the body? + if (((!ContentType.Application.contains(mimeType) && !ContentType.Text.contains(mimeType))) || mimeType.contentSubtype != "xml") { + /* Content-Type is not application/xml or text/xml although that is expected here. + Some broken servers return an XML response with some other MIME type. So we try to see + whether the response is maybe XML although the Content-Type is something else. */ + try { + val firstBytes = ByteArray(XML_SIGNATURE.size) + bodyChannel.readBuffer().peek().readFully(firstBytes) + if (XML_SIGNATURE.contentEquals(firstBytes)) { + logger.warn("Received 207 Multi-Status that seems to be XML but has MIME type $mimeType") + + // response is OK, return and do not throw Exception below + return } - - throw DavException("Received non-XML 207 Multi-Status", httpResponse = response) + } catch (e: Exception) { + logger.warn("Couldn't scan for XML signature", e) } - } ?: logger.warning("Received 207 Multi-Status without Content-Type, assuming XML") - } + + throw DavException("Received non-XML 207 Multi-Status", httpResponse = response) + } + } ?: logger.warn("Received 207 Multi-Status without Content-Type, assuming XML") } @@ -782,12 +808,10 @@ open class DavResource @JvmOverloads constructor( * @throws HttpException on HTTP error * @throws DavException on WebDAV error (for instance, when the response is not a Multi-Status response) */ - protected fun processMultiStatus(response: Response, callback: MultiResponseCallback): List { + protected suspend fun processMultiStatus(response: HttpResponse, callback: MultiResponseCallback): List { checkStatus(response) assertMultiStatus(response) - return response.body.use { - processMultiStatus(it.charStream(), callback) - } + return processMultiStatus(response.bodyAsBytes().inputStream().reader(), callback) } /** diff --git a/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt b/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt index f9cb4ff..bff55c8 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt @@ -11,8 +11,9 @@ package at.bitfire.dav4jvm import at.bitfire.dav4jvm.HttpUtils.httpDateFormat -import okhttp3.HttpUrl -import okhttp3.Response +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url import java.time.Instant import java.time.LocalDateTime import java.time.ZoneOffset @@ -30,6 +31,9 @@ object HttpUtils { private const val httpDateFormatStr = "EEE, dd MMM yyyy HH:mm:ss ZZZZ" private val httpDateFormat = DateTimeFormatter.ofPattern(httpDateFormatStr, Locale.US) + val INVALID_STATUS = HttpStatusCode( 500, "Invalid status line") + + private val logger get() = Logger.getLogger(javaClass.name) @@ -44,10 +48,7 @@ object HttpUtils { * * @return resource name */ - fun fileName(url: HttpUrl): String { - val pathSegments = url.pathSegments.dropLastWhile { it == "" } - return pathSegments.lastOrNull() ?: "" - } + fun fileName(url: Url): String = url.segments.lastOrNull() ?: "" // segments excludes empty segments /** * Gets all values of a header that is defined as a list [RFC 9110 5.6.1], @@ -73,9 +74,9 @@ object HttpUtils { * * @return all values for the given header name */ - fun listHeader(response: Response, name: String): Array { - val value = response.headers(name).joinToString(",") - return value.split(',').filter { it.isNotEmpty() }.toTypedArray() + fun listHeader(response: HttpResponse, name: String): Array { + val value = response.headers.getAll(name)?.joinToString(",") + return value?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.toTypedArray() ?: emptyArray() } @@ -129,4 +130,41 @@ object HttpUtils { return null } + /** + * Parses an HTTP status line. + * + * It supports both full status lines like "HTTP/1.1 200 OK" + * and partial ones like "200 OK" or just "200". + * + * If the status line cannot be parsed, an [HttpStatusCode] object + * with the value 500 "Invalid status line" is returned. + * + * @param statusText the status line to parse. + * @return an [HttpStatusCode] object representing the parsed status. + */ + fun parseStatusLine(statusText: String): HttpStatusCode { + + + val parts = statusText.split(" ", limit = 3) + return if (parts.size >= 2 && parts[0].startsWith("HTTP/")) { // Full status line like "HTTP/1.1 200 OK" + val statusCode = parts[1].toIntOrNull() + val description = if (parts.size > 2) parts[2] else "" + if (statusCode != null && statusCode in 1..999) { + HttpStatusCode(statusCode, description) + } else { + INVALID_STATUS + } + } else if (parts.isNotEmpty()) { // Potentially just "200 OK" or "200" + val statusCode = parts[0].toIntOrNull() + val description = if (parts.size > 1) parts.drop(1).joinToString(" ") else HttpStatusCode.allStatusCodes.find { it.value == statusCode }?.description ?: "" + if (statusCode != null && statusCode in 1..999) { + HttpStatusCode(statusCode, description) + } else { + INVALID_STATUS + } + } else { + INVALID_STATUS + } + } + } \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt b/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt index ed1d655..d35aa6c 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt @@ -13,10 +13,8 @@ package at.bitfire.dav4jvm import at.bitfire.dav4jvm.Response.Companion.STATUS import at.bitfire.dav4jvm.XmlUtils.propertyName import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV -import okhttp3.Protocol -import okhttp3.internal.http.StatusLine +import io.ktor.http.HttpStatusCode import org.xmlpull.v1.XmlPullParser -import java.net.ProtocolException import java.util.LinkedList /** @@ -26,7 +24,7 @@ import java.util.LinkedList */ data class PropStat( val properties: List, - val status: StatusLine, + val status: HttpStatusCode, val error: List? = null ) { @@ -35,13 +33,12 @@ data class PropStat( @JvmField val NAME = Property.Name(NS_WEBDAV, "propstat") - private val ASSUMING_OK = StatusLine(Protocol.HTTP_1_1, 200, "Assuming OK") - private val INVALID_STATUS = StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line") + private val ASSUMING_OK = HttpStatusCode(200, "Assuming OK") fun parse(parser: XmlPullParser): PropStat { val depth = parser.depth - var status: StatusLine? = null + var status: HttpStatusCode? = null val prop = LinkedList() var eventType = parser.eventType @@ -50,14 +47,7 @@ data class PropStat( when (parser.propertyName()) { DavResource.PROP -> prop.addAll(Property.parse(parser)) - STATUS -> - status = try { - StatusLine.parse(parser.nextText()) - } catch (e: ProtocolException) { - // invalid status line, treat as 500 Internal Server Error - INVALID_STATUS - } - } + STATUS -> status = HttpUtils.parseStatusLine(parser.nextText()) } eventType = parser.next() } @@ -65,8 +55,4 @@ data class PropStat( } } - - - fun isSuccess() = status.code/100 == 2 - } \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/Property.kt b/src/main/kotlin/at/bitfire/dav4jvm/Property.kt index 7b0452e..62c1ebe 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/Property.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/Property.kt @@ -11,11 +11,10 @@ package at.bitfire.dav4jvm import at.bitfire.dav4jvm.exception.InvalidPropertyException +import org.slf4j.LoggerFactory import org.xmlpull.v1.XmlPullParser import java.io.Serializable import java.util.LinkedList -import java.util.logging.Level -import java.util.logging.Logger /** * Represents a WebDAV property. @@ -40,7 +39,7 @@ interface Property { companion object { fun parse(parser: XmlPullParser): List { - val logger = Logger.getLogger(Property::javaClass.name) + val logger = LoggerFactory.getLogger(Property::javaClass.name) // val depth = parser.depth @@ -59,9 +58,9 @@ interface Property { if (property != null) { properties.add(property) } else - logger.fine("Ignoring unknown property $name") + logger.info("Ignoring unknown property $name") } catch (e: InvalidPropertyException) { - logger.log(Level.WARNING, "Ignoring invalid property", e) + logger.warn("Ignoring invalid property", e) } } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/Response.kt b/src/main/kotlin/at/bitfire/dav4jvm/Response.kt index 6a474ef..0cbee0c 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/Response.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/Response.kt @@ -13,12 +13,12 @@ package at.bitfire.dav4jvm import at.bitfire.dav4jvm.XmlUtils.propertyName import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV import at.bitfire.dav4jvm.property.webdav.ResourceType -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.Protocol -import okhttp3.internal.http.StatusLine +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.isSuccess +import io.ktor.http.takeFrom import org.xmlpull.v1.XmlPullParser -import java.net.ProtocolException import java.util.logging.Logger /** @@ -34,17 +34,17 @@ data class Response( * of a PROPFIND request, the `requestedUrl` would be the URL where the * PROPFIND request has been sent to (usually the collection URL). */ - val requestedUrl: HttpUrl, + val requestedUrl: Url, /** * URL of this response (`href` element) */ - val href: HttpUrl, + val href: Url, /** * status of this response (`status` XML element) */ - val status: StatusLine?, + val status: HttpStatusCode?, /** * property/status elements (`propstat` XML elements) @@ -59,7 +59,7 @@ data class Response( /** * new location of this response (`location` XML element), used for redirects */ - val newLocation: HttpUrl? = null + val newLocation: Url? = null ) { enum class HrefRelation { @@ -71,7 +71,7 @@ data class Response( */ val properties: List by lazy { if (isSuccess()) - propstat.filter { it.isSuccess() }.map { it.properties }.flatten() + propstat.filter { it.status.isSuccess() }.map { it.properties }.flatten() else emptyList() } @@ -88,7 +88,7 @@ data class Response( * * @return true: no status XML element or status code 2xx; false: otherwise */ - fun isSuccess() = status == null || status.code/100 == 2 + fun isSuccess() = status == null || status.isSuccess() /** * Returns the name (last path segment) of the resource. @@ -114,16 +114,16 @@ data class Response( * So if you want PROPFIND results to have a trailing slash when they are collections, make sure * that you query [ResourceType]. */ - fun parse(parser: XmlPullParser, location: HttpUrl, callback: MultiResponseCallback) { + fun parse(parser: XmlPullParser, location: Url, callback: MultiResponseCallback) { val logger = Logger.getLogger(Response::javaClass.name) val depth = parser.depth - var hrefOrNull: HttpUrl? = null - var status: StatusLine? = null + var hrefOrNull: Url? = null + var status: HttpStatusCode? = null val propStat = mutableListOf() var error: List? = null - var newLocation: HttpUrl? = null + var newLocation: Url? = null var eventType = parser.eventType while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { @@ -131,6 +131,7 @@ data class Response( when (parser.propertyName()) { DavResource.HREF -> { var sHref = parser.nextText() + var hierarchical = false if (!sHref.startsWith("/")) { /* According to RFC 4918 8.3 URL Handling, only absolute paths are allowed as relative URLs. However, some servers reply with relative paths. */ @@ -140,7 +141,6 @@ data class Response( which would be interpreted as scheme: "a", scheme-specific part: "b.vcf" normally. For maximum compatibility, we prefix all relative paths which contain ":" (but not "://"), with "./" to allow resolving by HttpUrl. */ - var hierarchical = false try { if (sHref.substring(firstColon, firstColon + 3) == "://") hierarchical = true @@ -149,23 +149,27 @@ data class Response( } if (!hierarchical) sHref = "./$sHref" + } } - hrefOrNull = location.resolve(sHref) + + + if(!hierarchical) { + val urlBuilder = URLBuilder(location).takeFrom(sHref) + urlBuilder.pathSegments = urlBuilder.pathSegments.filterNot { it == "." } // Drop segments that are "./" + hrefOrNull = urlBuilder.build() + } else { + hrefOrNull = URLBuilder(location).takeFrom(sHref).build() + } } STATUS -> - status = try { - StatusLine.parse(parser.nextText()) - } catch(e: ProtocolException) { - logger.warning("Invalid status line, treating as HTTP error 500") - StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line") - } + status = HttpUtils.parseStatusLine(parser.nextText()) PropStat.NAME -> PropStat.parse(parser).let { propStat += it } Error.NAME -> error = Error.parseError(parser) LOCATION -> - newLocation = parser.nextText().toHttpUrlOrNull() + newLocation = Url(parser.nextText()) // TODO: Need to catch exception here? } eventType = parser.next() } @@ -174,11 +178,11 @@ data class Response( logger.warning("Ignoring XML response element without valid href") return } - var href: HttpUrl = hrefOrNull // guaranteed to be not null + var href: Url = hrefOrNull // guaranteed to be not null // if we know this resource is a collection, make sure href has a trailing slash // (for clarity and resolving relative paths) - propStat.filter { it.isSuccess() } + propStat.filter { it.status.isSuccess() } .map { it.properties } .filterIsInstance() .firstOrNull() @@ -195,13 +199,13 @@ data class Response( HrefRelation.SELF else -> { - if (location.scheme == href.scheme && location.host == href.host && location.port == href.port) { - val locationSegments = location.pathSegments - val hrefSegments = href.pathSegments + if (location.protocol.name == href.protocol.name && location.host == href.host && location.port == href.port) { + val locationSegments = location.segments + val hrefSegments = href.segments // don't compare trailing slash segment ("") var nBasePathSegments = locationSegments.size - if (locationSegments[nBasePathSegments - 1] == "") + if (locationSegments[nBasePathSegments - 1] == "") // TODO: Ricki, Not sure if this is still needed nBasePathSegments-- /* example: locationSegments = [ "davCollection", "" ] diff --git a/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt b/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt index cffb18e..32717de 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt @@ -12,7 +12,8 @@ package at.bitfire.dav4jvm import at.bitfire.dav4jvm.UrlUtils.omitTrailingSlash import at.bitfire.dav4jvm.UrlUtils.withTrailingSlash -import okhttp3.HttpUrl +import io.ktor.http.URLBuilder +import io.ktor.http.Url object UrlUtils { @@ -47,12 +48,11 @@ object UrlUtils { * * @return URL without trailing slash (except when the path is the root path), e.g. `http://host/test1` */ - fun omitTrailingSlash(url: HttpUrl): HttpUrl { - val idxLast = url.pathSize - 1 - val hasTrailingSlash = url.pathSegments[idxLast] == "" + fun omitTrailingSlash(url: Url): Url { + val hasTrailingSlash = url.rawSegments.last() == "" return if (hasTrailingSlash) - url.newBuilder().removePathSegment(idxLast).build() + URLBuilder(url).apply { pathSegments = pathSegments.dropLast(1) }.build() else url } @@ -64,28 +64,27 @@ object UrlUtils { * * @return URL with trailing slash, e.g. `http://host/test1/` */ - fun withTrailingSlash(url: HttpUrl): HttpUrl { - val idxLast = url.pathSize - 1 - val hasTrailingSlash = url.pathSegments[idxLast] == "" + fun withTrailingSlash(url: Url): Url { + val hasTrailingSlash = url.rawSegments.last() == "" return if (hasTrailingSlash) url else - url.newBuilder().addPathSegment("").build() + URLBuilder(url).apply { pathSegments += "" }.build() } } /** - * Compares two [HttpUrl]s in WebDAV context. If two URLs are considered *equal*, both + * Compares two [Url]s in WebDAV context. If two URLs are considered *equal*, both * represent the same WebDAV resource. * * The fragment of an URL is ignored, e.g. `http://host:80/folder1` and `http://HOST/folder1#somefragment` * are considered to be equal. * - * [HttpUrl] is less strict than [java.net.URI] and allows for instance (not encoded) square brackets in the path. - * So this method tries to normalize the URI by converting it to a [java.net.URI] (encodes for instance square brackets) - * and then comparing the scheme and scheme-specific part (without fragment). + * [Url] is less strict than [java.net.URI] and allows for instance (not encoded) square brackets in the path. + * So this method compares the protocol, host (case insensitive), port and rawSegments + * and and returns true if all are the same. * * Attention: **This method does not deal with trailing slashes**, so if you want to compare collection URLs, * make sure they both (don't) have a trailing slash before calling this method, for instance @@ -95,16 +94,15 @@ object UrlUtils { * * @return whether the URLs are considered to represent the same WebDAV resource */ -fun HttpUrl.equalsForWebDAV(other: HttpUrl): Boolean { - // if okhttp thinks the two URLs are equal, they're in any case +fun Url.equalsForWebDAV(other: Url): Boolean { + // if Ktor thinks the two URLs are equal, they're in any case // (and it's a simple String comparison) if (this == other) return true - // convert to java.net.URI (also corrects some mistakes and escapes for instance square brackets) - val uri1 = toUri() - val uri2 = other.toUri() - - // if the URIs are the same (ignoring scheme case and fragments), they're equal for us - return uri1.scheme.equals(uri2.scheme, true) && uri1.schemeSpecificPart == uri2.schemeSpecificPart + //TODO: Check with Ricki if this is ok like that, update description + return this.protocol == other.protocol + && this.host.lowercase() == other.host.lowercase() + && this.port == other.port + && this.rawSegments == other.rawSegments } \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/XmlReader.kt b/src/main/kotlin/at/bitfire/dav4jvm/XmlReader.kt index 460ffa4..5076767 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/XmlReader.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/XmlReader.kt @@ -13,8 +13,7 @@ package at.bitfire.dav4jvm import at.bitfire.dav4jvm.XmlUtils.propertyName import at.bitfire.dav4jvm.property.caldav.SupportedCalendarData.Companion.CONTENT_TYPE import at.bitfire.dav4jvm.property.caldav.SupportedCalendarData.Companion.VERSION -import okhttp3.MediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull +import io.ktor.http.ContentType import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException import java.io.IOException @@ -160,15 +159,15 @@ class XmlReader( * attribute with [onNewType]. * * @param tagName The name of the tag that contains the [CONTENT_TYPE] attribute value. - * @param onNewType Called every time a new [MediaType] is found. + * @param onNewType Called every time a new [ContentType] is found. */ - fun readContentTypes(tagName: Property.Name, onNewType: (MediaType) -> Unit) { + fun readContentTypes(tagName: Property.Name, onNewType: (ContentType) -> Unit) { try { processTag(tagName) { parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType -> var type = contentType parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" } - type.toMediaTypeOrNull()?.let(onNewType) + ContentType.parse(type).let(onNewType) // TODO: Check with Ricki how to properly catch the exception for content type parsing. Throw the same exception as below? } } } catch(e: XmlPullParserException) { diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt index 03e52c4..9494af1 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt @@ -10,12 +10,12 @@ package at.bitfire.dav4jvm.exception -import okhttp3.Response -import java.net.HttpURLConnection +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode class ConflictException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_CONFLICT, message) + constructor(response: HttpResponse): super(response) + constructor(message: String?): super(HttpStatusCode.Conflict.value, message) } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt index 19e8042..2695e1f 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt @@ -14,13 +14,23 @@ import at.bitfire.dav4jvm.Error import at.bitfire.dav4jvm.XmlUtils import at.bitfire.dav4jvm.XmlUtils.propertyName import at.bitfire.dav4jvm.exception.DavException.Companion.MAX_EXCERPT_SIZE -import okhttp3.MediaType -import okhttp3.Response -import okio.Buffer +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.readRawBytes +import io.ktor.client.statement.request +import io.ktor.http.ContentType +import io.ktor.http.charset +import io.ktor.http.content.ByteArrayContent +import io.ktor.http.content.TextContent +import io.ktor.http.contentType +import kotlinx.coroutines.runBlocking +import kotlinx.io.Buffer +import kotlinx.io.readByteArray import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException +import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.IOException +import java.io.InputStreamReader import java.io.Serializable import java.lang.Long.min import java.util.logging.Level @@ -37,18 +47,19 @@ open class DavException @JvmOverloads constructor( ex: Throwable? = null, /** - * An associated HTTP [Response]. Will be closed after evaluation. + * An associated HTTP [HttpResponse]. Will be closed after evaluation. */ - httpResponse: Response? = null + httpResponse: HttpResponse? = null ): Exception(message, ex), Serializable { companion object { const val MAX_EXCERPT_SIZE = 10*1024 // don't dump more than 20 kB - fun isPlainText(type: MediaType) = - type.type == "text" || - (type.type == "application" && type.subtype in arrayOf("html", "xml")) + fun isPlainText(type: ContentType) = + type.match(ContentType.Text.Any) + || type.match(ContentType.Application.Xml) + || (type.contentType == ContentType.Application.TYPE && type.contentSubtype == ContentType.Text.Html.contentSubtype) // not sure if this is a valid case. TODO: Review with Ricki } @@ -76,27 +87,35 @@ open class DavException @JvmOverloads constructor( /** * Precondition/postcondition XML elements which have been found in the XML response. */ + @Transient var errors: List = listOf() private set init { if (httpResponse != null) { - response = httpResponse.toString() + response = httpResponse.toString() // Or a more custom string representation try { request = httpResponse.request.toString() - httpResponse.request.body?.let { body -> - body.contentType()?.let { type -> + httpResponse.request.content.let { body -> + body.contentType?.let { type -> if (isPlainText(type)) { + val requestBodyBytes = when (body) { + is TextContent -> body.text.toByteArray(type.charset() ?: Charsets.UTF_8) + is ByteArrayContent -> body.bytes() + else -> body.toString().toByteArray(type.charset() ?: Charsets.UTF_8) // Fallback, may not be ideal + } val buffer = Buffer() - body.writeTo(buffer) + buffer.write(requestBodyBytes) + val bytesToRead = min(buffer.size, MAX_EXCERPT_SIZE.toLong()).toInt() + val excerptBytes = buffer.readByteArray(bytesToRead) val baos = ByteArrayOutputStream() - buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong())) + baos.write(excerptBytes) - requestBody = baos.toString(type.charset(Charsets.UTF_8)!!.name()) + requestBody = baos.toString((type.charset()?: Charsets.UTF_8).name()) } } } @@ -106,47 +125,49 @@ open class DavException @JvmOverloads constructor( } try { - // save response body excerpt - if (httpResponse.body?.source() != null) { - // response body has a source - - httpResponse.peekBody(MAX_EXCERPT_SIZE.toLong()).let { body -> - body.contentType()?.let { mimeType -> - if (isPlainText(mimeType)) - responseBody = body.string() - } + val responseBytes = runBlocking { httpResponse.readRawBytes() } // Read the whole body + + httpResponse.contentType()?.let { mimeType -> + if (isPlainText(mimeType)) { + val charset = mimeType.charset() ?: Charsets.UTF_8 + responseBody = String( + responseBytes, + 0, + min(responseBytes.size.toLong(), MAX_EXCERPT_SIZE.toLong()).toInt(), + charset + ) } - httpResponse.body?.use { body -> - body.contentType()?.let { - if (it.type in arrayOf("application", "text") && it.subtype == "xml") { - // look for precondition/postcondition XML elements - try { - val parser = XmlUtils.newPullParser() - parser.setInput(body.charStream()) - - var eventType = parser.eventType - while (eventType != XmlPullParser.END_DOCUMENT) { - if (eventType == XmlPullParser.START_TAG && parser.depth == 1) - if (parser.propertyName() == Error.NAME) - errors = Error.parseError(parser) - eventType = parser.next() + if (mimeType.contentType == "application" && mimeType.contentSubtype == "xml" || + mimeType.contentType == "text" && mimeType.contentSubtype == "xml") { + try { + val parser = XmlUtils.newPullParser() + // Use the already read bytes + parser.setInput(InputStreamReader(ByteArrayInputStream(responseBytes), mimeType.charset() ?: Charsets.UTF_8)) + + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.depth == 1) { + if (parser.propertyName() == Error.NAME) { + errors = Error.parseError(parser) } - } catch (e: XmlPullParserException) { - logger.log(Level.WARNING, "Couldn't parse XML response", e) } + eventType = parser.next() } + } catch (e: XmlPullParserException) { + logger.log(Level.WARNING, "Couldn't parse XML response", e) + } catch (e: IOException) { // Catch IOException from parser.setInput + logger.log(Level.WARNING, "Couldn't set input for XML parser", e) } } } - } catch (e: IOException) { - logger.log(Level.WARNING, "Couldn't read HTTP response", e) - responseBody = "Couldn't read HTTP response: ${e.message}" - } finally { - httpResponse.body?.close() + } catch (e: Exception) { // Broader catch for Ktor's body reading/statement exceptions + logger.log(Level.WARNING, "Couldn't read Ktor HTTP response", e) + responseBody = "Couldn't read Ktor HTTP response: ${e.message}" } - } else + } else { response = null + } } } \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt index 884cc00..c81f2fd 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt @@ -10,12 +10,12 @@ package at.bitfire.dav4jvm.exception -import okhttp3.Response -import java.net.HttpURLConnection +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode class ForbiddenException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_FORBIDDEN, message) + constructor(response: HttpResponse): super(response) + constructor(message: String?): super(HttpStatusCode.Forbidden.value, message) } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt index 0cbd9b3..003ab18 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt @@ -10,12 +10,12 @@ package at.bitfire.dav4jvm.exception -import okhttp3.Response -import java.net.HttpURLConnection +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode class GoneException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_GONE, message) + constructor(response: HttpResponse): super(response) + constructor(message: String?): super(HttpStatusCode.Gone.value, message) } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt index 08534a4..14cf545 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt @@ -10,7 +10,7 @@ package at.bitfire.dav4jvm.exception -import okhttp3.Response +import io.ktor.client.statement.HttpResponse /** * Signals that a HTTP error was sent by the server. @@ -19,11 +19,11 @@ open class HttpException: DavException { var code: Int - constructor(response: Response): super( - "HTTP ${response.code} ${response.message}", + constructor(response: HttpResponse): super( + "HTTP ${response.status.value} ${response.status.description}", httpResponse = response ) { - code = response.code + code = response.status.value } constructor(code: Int, message: String?): super("HTTP $code $message") { diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt index d04c0ac..ff15834 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt @@ -10,12 +10,12 @@ package at.bitfire.dav4jvm.exception -import okhttp3.Response -import java.net.HttpURLConnection +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode class NotFoundException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_NOT_FOUND, message) + constructor(response: HttpResponse): super(response) + constructor(message: String?): super(HttpStatusCode.NotFound.value, message) } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt index 6760736..e10a19a 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt @@ -10,12 +10,12 @@ package at.bitfire.dav4jvm.exception -import okhttp3.Response -import java.net.HttpURLConnection +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode class PreconditionFailedException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_PRECON_FAILED, message) + constructor(response: HttpResponse): super(response) + constructor(message: String?): super(HttpStatusCode.PreconditionFailed.value, message) } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt index 0665fc0..24191b9 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt @@ -14,8 +14,9 @@ import at.bitfire.dav4jvm.HttpUtils import at.bitfire.dav4jvm.exception.ServiceUnavailableException.Companion.DELAY_UNTIL_DEFAULT import at.bitfire.dav4jvm.exception.ServiceUnavailableException.Companion.DELAY_UNTIL_MAX import at.bitfire.dav4jvm.exception.ServiceUnavailableException.Companion.DELAY_UNTIL_MIN -import okhttp3.Response -import java.net.HttpURLConnection +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode import java.time.Instant import java.util.logging.Level import java.util.logging.Logger @@ -27,16 +28,16 @@ class ServiceUnavailableException : HttpException { val retryAfter: Instant? - constructor(message: String?) : super(HttpURLConnection.HTTP_UNAVAILABLE, message) { + constructor(message: String?) : super(HttpStatusCode.ServiceUnavailable.value, message) { retryAfter = null } - constructor(response: Response) : super(response) { + constructor(response: HttpResponse) : super(response) { // Retry-After = "Retry-After" ":" ( HTTP-date | delta-seconds ) // HTTP-date = rfc1123-date | rfc850-date | asctime-date var retryAfterValue: Instant? = null - response.header("Retry-After")?.let { after -> + response.headers[HttpHeaders.RetryAfter]?.let { after -> retryAfterValue = HttpUtils.parseDate(after) ?: // not a HTTP-date, must be delta-seconds try { diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt index 98a6183..84df35d 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt @@ -10,12 +10,12 @@ package at.bitfire.dav4jvm.exception -import okhttp3.Response -import java.net.HttpURLConnection +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode class UnauthorizedException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_UNAUTHORIZED, message) + constructor(response: HttpResponse): super(response) + constructor(message: String?): super(HttpStatusCode.Unauthorized.value, message) } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/property/caldav/ScheduleTag.kt b/src/main/kotlin/at/bitfire/dav4jvm/property/caldav/ScheduleTag.kt index 3aaaa17..5f169d4 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/property/caldav/ScheduleTag.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/property/caldav/ScheduleTag.kt @@ -14,7 +14,8 @@ import at.bitfire.dav4jvm.Property import at.bitfire.dav4jvm.PropertyFactory import at.bitfire.dav4jvm.QuotedStringUtils import at.bitfire.dav4jvm.XmlReader -import okhttp3.Response +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpHeaders import org.xmlpull.v1.XmlPullParser data class ScheduleTag( @@ -26,8 +27,8 @@ data class ScheduleTag( @JvmField val NAME = Property.Name(NS_CALDAV, "schedule-tag") - fun fromResponse(response: Response) = - response.header("Schedule-Tag")?.let { ScheduleTag(it) } + fun fromResponse(response: HttpResponse) = + response.headers[HttpHeaders.ScheduleTag]?.let { ScheduleTag(it) } } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/property/caldav/SupportedCalendarData.kt b/src/main/kotlin/at/bitfire/dav4jvm/property/caldav/SupportedCalendarData.kt index 40401c9..24f1986 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/property/caldav/SupportedCalendarData.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/property/caldav/SupportedCalendarData.kt @@ -13,11 +13,11 @@ package at.bitfire.dav4jvm.property.caldav import at.bitfire.dav4jvm.Property import at.bitfire.dav4jvm.PropertyFactory import at.bitfire.dav4jvm.XmlReader -import okhttp3.MediaType +import io.ktor.http.ContentType import org.xmlpull.v1.XmlPullParser data class SupportedCalendarData( - val types: Set = emptySet() + val types: Set = emptySet() ): Property { companion object { @@ -31,7 +31,7 @@ data class SupportedCalendarData( } - fun hasJCal() = types.any { "application".equals(it.type, true) && "calendar+json".equals(it.subtype, true) } + fun hasJCal() = types.any { ContentType.Application.contains(it) && "calendar+json".equals(it.contentSubtype, true) } object Factory: PropertyFactory { @@ -39,7 +39,7 @@ data class SupportedCalendarData( override fun getName() = NAME override fun create(parser: XmlPullParser): SupportedCalendarData { - val supportedTypes = mutableSetOf() + val supportedTypes = mutableSetOf() XmlReader(parser).readContentTypes(CALENDAR_DATA_TYPE, supportedTypes::add) diff --git a/src/main/kotlin/at/bitfire/dav4jvm/property/carddav/SupportedAddressData.kt b/src/main/kotlin/at/bitfire/dav4jvm/property/carddav/SupportedAddressData.kt index e30960b..013f8ae 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/property/carddav/SupportedAddressData.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/property/carddav/SupportedAddressData.kt @@ -13,11 +13,11 @@ package at.bitfire.dav4jvm.property.carddav import at.bitfire.dav4jvm.Property import at.bitfire.dav4jvm.PropertyFactory import at.bitfire.dav4jvm.XmlReader -import okhttp3.MediaType +import io.ktor.http.ContentType import org.xmlpull.v1.XmlPullParser class SupportedAddressData( - val types: Set = emptySet() + val types: Set = emptySet() ): Property { companion object { @@ -32,7 +32,7 @@ class SupportedAddressData( } fun hasVCard4() = types.any { "text/vcard; version=4.0".equals(it.toString(), true) } - fun hasJCard() = types.any { "application".equals(it.type, true) && "vcard+json".equals(it.subtype, true) } + fun hasJCard() = types.any { ContentType.Application.contains(it) && "vcard+json".equals(it.contentSubtype, true) } override fun toString() = "[${types.joinToString(", ")}]" @@ -42,7 +42,7 @@ class SupportedAddressData( override fun getName() = NAME override fun create(parser: XmlPullParser): SupportedAddressData { - val supportedTypes = mutableSetOf() + val supportedTypes = mutableSetOf() XmlReader(parser).readContentTypes(ADDRESS_DATA_TYPE, supportedTypes::add) diff --git a/src/main/kotlin/at/bitfire/dav4jvm/property/webdav/GetContentType.kt b/src/main/kotlin/at/bitfire/dav4jvm/property/webdav/GetContentType.kt index 71ca619..732d81e 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/property/webdav/GetContentType.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/property/webdav/GetContentType.kt @@ -13,12 +13,11 @@ package at.bitfire.dav4jvm.property.webdav import at.bitfire.dav4jvm.Property import at.bitfire.dav4jvm.PropertyFactory import at.bitfire.dav4jvm.XmlReader -import okhttp3.MediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull +import io.ktor.http.ContentType import org.xmlpull.v1.XmlPullParser data class GetContentType( - val type: MediaType? + val type: ContentType? ): Property { companion object { @@ -35,7 +34,9 @@ data class GetContentType( override fun create(parser: XmlPullParser) = // - GetContentType(XmlReader(parser).readText()?.toMediaTypeOrNull()) + GetContentType(XmlReader(parser).readText()?.let { + ContentType.parse(it) // TODO: Check with Ricki how to properly catch the exception for content type parsing + }) } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/property/webdav/GetETag.kt b/src/main/kotlin/at/bitfire/dav4jvm/property/webdav/GetETag.kt index ed9b84b..61e7bfd 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/property/webdav/GetETag.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/property/webdav/GetETag.kt @@ -14,7 +14,8 @@ import at.bitfire.dav4jvm.Property import at.bitfire.dav4jvm.PropertyFactory import at.bitfire.dav4jvm.QuotedStringUtils import at.bitfire.dav4jvm.XmlReader -import okhttp3.Response +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpHeaders import org.xmlpull.v1.XmlPullParser /** @@ -31,8 +32,8 @@ data class GetETag( @JvmField val NAME = Property.Name(NS_WEBDAV, "getetag") - fun fromResponse(response: Response) = - response.header("ETag")?.let { GetETag(it) } + fun fromResponse(response: HttpResponse) = + response.headers[HttpHeaders.ETag]?.let { GetETag(it) } } /** diff --git a/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt deleted file mode 100644 index 6a6ab3a..0000000 --- a/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt +++ /dev/null @@ -1,289 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ - -package at.bitfire.dav4jvm - -import okhttp3.Challenge -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response.Builder -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class BasicDigestAuthHandlerTest { - - @Test - fun testBasic() { - var authenticator = BasicDigestAuthHandler(null, "user", "password".toCharArray()) - val original = Request.Builder() - .url("http://example.com") - .build() - var response = Builder() - .request(original) - .protocol(Protocol.HTTP_1_1) - .code(401).message("Authentication required") - .header("WWW-Authenticate", "Basic realm=\"WallyWorld\"") - .build() - var request = authenticator.authenticateRequest(original, response) - assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization")) - - // special characters: always use UTF-8 (and don't crash on RFC 7617 charset header) - authenticator = BasicDigestAuthHandler(null, "username", "paßword".toCharArray()) - response = response.newBuilder() - .header("WWW-Authenticate", "Basic realm=\"WallyWorld\",charset=UTF-8") - .build() - request = authenticator.authenticateRequest(original, response) - assertEquals("Basic dXNlcm5hbWU6cGHDn3dvcmQ=", request!!.header("Authorization")) - } - - @Test - fun testDigestRFCExample() { - // use cnonce from example - val authenticator = BasicDigestAuthHandler(null, "Mufasa", "Circle Of Life".toCharArray()) - BasicDigestAuthHandler.clientNonce = "0a4f113b" - BasicDigestAuthHandler.nonceCount.set(1) - - // construct WWW-Authenticate - val authScheme = Challenge("Digest", mapOf( - Pair("realm", "testrealm@host.com"), - Pair("qop", "auth"), - Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"), - Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41") - )) - - val original = Request.Builder() - .get() - .url("http://www.nowhere.org/dir/index.html") - .build() - val request = authenticator.digestRequest(original, authScheme) - val auth = request!!.header("Authorization") - assertTrue(auth!!.contains("username=\"Mufasa\"")) - assertTrue(auth.contains("realm=\"testrealm@host.com\"")) - assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\"")) - assertTrue(auth.contains("uri=\"/dir/index.html\"")) - assertTrue(auth.contains("qop=auth")) - assertTrue(auth.contains("nc=00000001")) - assertTrue(auth.contains("cnonce=\"0a4f113b\"")) - assertTrue(auth.contains("response=\"6629fae49393a05397450978507c4ef1\"")) - assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")) - } - - @Test - fun testDigestRealWorldExamples() { - var authenticator = BasicDigestAuthHandler(null, "demo", "demo".toCharArray()) - BasicDigestAuthHandler.clientNonce = "MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=" - BasicDigestAuthHandler.nonceCount.set(1) - - // example 1 - var authScheme = Challenge("Digest", mapOf( - Pair("realm", "Group-Office"), - Pair("qop", "auth"), - Pair("nonce", "56212407212c8"), - Pair("opaque", "df58bdff8cf60599c939187d0b5c54de") - )) - - var original = Request.Builder() - .method("PROPFIND", null) - .url("https://demo.group-office.eu/caldav/") - .build() - var request = authenticator.digestRequest(original, authScheme) - var auth = request!!.header("Authorization") - assertTrue(auth!!.contains("username=\"demo\"")) - assertTrue(auth.contains("realm=\"Group-Office\"")) - assertTrue(auth.contains("nonce=\"56212407212c8\"")) - assertTrue(auth.contains("uri=\"/caldav/\"")) - assertTrue(auth.contains("cnonce=\"MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=\"")) - assertTrue(auth.contains("nc=00000001")) - assertTrue(auth.contains("qop=auth")) - assertTrue(auth.contains("response=\"de3b3b194d85ddc62537208c9c3637dc\"")) - assertTrue(auth.contains("opaque=\"df58bdff8cf60599c939187d0b5c54de\"")) - - // example 2 - authenticator = BasicDigestAuthHandler(null, "test", "test".toCharArray()) - authScheme = Challenge("digest", mapOf( // lower case - Pair("nonce", "87c4c2aceed9abf30dd68c71"), - Pair("algorithm", "md5"), - Pair("opaque", "571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw=="), - Pair("realm", "ieddy.ru") - )) - original = Request.Builder() - .method("OPTIONS", null) - .url("https://ieddy.ru/") - .build() - request = authenticator.digestRequest(original, authScheme) - auth = request!!.header("Authorization") - assertTrue(auth!!.contains("algorithm=\"MD5\"")) // some servers require it - assertTrue(auth.contains("username=\"test\"")) - assertTrue(auth.contains("realm=\"ieddy.ru\"")) - assertTrue(auth.contains("nonce=\"87c4c2aceed9abf30dd68c71\"")) - assertTrue(auth.contains("uri=\"/\"")) - assertFalse(auth.contains("cnonce=")) - assertFalse(auth.contains("nc=00000001")) - assertFalse(auth.contains("qop=")) - assertTrue(auth.contains("response=\"d42a39f25f80b0d6907286a960ff9c7d\"")) - assertTrue(auth.contains("opaque=\"571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw==\"")) - } - - @Test - fun testDigestMD5Sess() { - val authenticator = BasicDigestAuthHandler(null, "admin", "12345".toCharArray()) - BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk" - BasicDigestAuthHandler.nonceCount.set(1) - - val authScheme = Challenge("Digest", mapOf( - Pair("realm", "MD5-sess Example"), - Pair("qop", "auth"), - Pair("algorithm", "MD5-sess"), - Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"), - Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41") - )) - - /* A1 = h("admin:MD5-sess Example:12345"):dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk = - 4eaed818bc587129e73b39c8d3e8425a:dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk a994ee9d33e2f077d3a6e13e882f6686 - A2 = POST:/plain.txt 1b557703454e1aa1230c5523f54380ed - - h("a994ee9d33e2f077d3a6e13e882f6686:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:hxk1lu63b6c7vhk:auth:1b557703454e1aa1230c5523f54380ed") = - af2a72145775cfd08c36ad2676e89446 - */ - - val original = Request.Builder() - .method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType())) - .url("http://example.com/plain.txt") - .build() - val request = authenticator.digestRequest(original, authScheme) - val auth = request!!.header("Authorization") - assertTrue(auth!!.contains("username=\"admin\"")) - assertTrue(auth.contains("realm=\"MD5-sess Example\"")) - assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\"")) - assertTrue(auth.contains("uri=\"/plain.txt\"")) - assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\"")) - assertTrue(auth.contains("nc=00000001")) - assertTrue(auth.contains("qop=auth")) - assertTrue(auth.contains("response=\"af2a72145775cfd08c36ad2676e89446\"")) - assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")) - } - - @Test - fun testDigestMD5AuthInt() { - val authenticator = BasicDigestAuthHandler(null, "admin", "12435".toCharArray()) - BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk" - BasicDigestAuthHandler.nonceCount.set(1) - - val authScheme = Challenge("Digest", mapOf( - Pair("realm", "AuthInt Example"), - Pair("qop", "auth-int"), - Pair("nonce", "367sj3265s5"), - Pair("opaque", "87aaxcval4gba36") - )) - - /* A1 = admin:AuthInt Example:12345 380dc3fc1305127cd2aa81ab68ef3f34 - - h("PLAIN TEXT") = 20296edbd4c4275fb416b64e4be752f9 - A2 = POST:/plain.txt:20296edbd4c4275fb416b64e4be752f9 a71c4c86e18b3993ffc98c6e426fe4b0 - - h(380dc3fc1305127cd2aa81ab68ef3f34:367sj3265s5:00000001:hxk1lu63b6c7vhk:auth-int:a71c4c86e18b3993ffc98c6e426fe4b0) = - 81d07cb3b8d412b34144164124c970cb - */ - - val original = Request.Builder() - .method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType())) - .url("http://example.com/plain.txt") - .build() - val request = authenticator.digestRequest(original, authScheme) - val auth = request!!.header("Authorization") - assertTrue(auth!!.contains("username=\"admin\"")) - assertTrue(auth.contains("realm=\"AuthInt Example\"")) - assertTrue(auth.contains("nonce=\"367sj3265s5\"")) - assertTrue(auth.contains("uri=\"/plain.txt\"")) - assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\"")) - assertTrue(auth.contains("nc=00000001")) - assertTrue(auth.contains("qop=auth-int")) - assertTrue(auth.contains("response=\"5ab6822b9d906cc711760a7783b28dca\"")) - assertTrue(auth.contains("opaque=\"87aaxcval4gba36\"")) - } - - @Test - fun testDigestLegacy() { - val authenticator = BasicDigestAuthHandler(null, "Mufasa", "CircleOfLife".toCharArray()) - - // construct WWW-Authenticate - val authScheme = Challenge("Digest", mapOf( - Pair("realm", "testrealm@host.com"), - Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"), - Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41") - )) - - val original = Request.Builder() - .get() - .url("http://www.nowhere.org/dir/index.html") - .build() - val request = authenticator.digestRequest(original, authScheme) - val auth = request!!.header("Authorization") - assertTrue(auth!!.contains("username=\"Mufasa\"")) - assertTrue(auth.contains("realm=\"testrealm@host.com\"")) - assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\"")) - assertTrue(auth.contains("uri=\"/dir/index.html\"")) - assertFalse(auth.contains("qop=")) - assertFalse(auth.contains("nc=")) - assertFalse(auth.contains("cnonce=")) - assertTrue(auth.contains("response=\"1949323746fe6a43ef61f9606e7febea\"")) - assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")) - } - - @Test - fun testIncompleteAuthenticationRequests() { - val authenticator = BasicDigestAuthHandler(null, "demo", "demo".toCharArray()) - - val original = Request.Builder() - .get() - .url("http://www.nowhere.org/dir/index.html") - .build() - - assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf()))) - - assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( - Pair("realm", "Group-Office") - )))) - - assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( - Pair("realm", "Group-Office"), - Pair("qop", "auth") - )))) - - assertNotNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( - Pair("realm", "Group-Office"), - Pair("qop", "auth"), - Pair("nonce", "56212407212c8") - )))) - } - - @Test - fun testAuthenticateNull() { - val authenticator = BasicDigestAuthHandler(null, "demo", "demo".toCharArray()) - // must not crash (route may be null) - val request = Request.Builder() - .get() - .url("http://example.com") - .build() - val response = Builder() - .request(request) - .protocol(Protocol.HTTP_2) - .code(200).message("OK") - .build() - authenticator.authenticate(null, response) - } - -} diff --git a/src/test/kotlin/at/bitfire/dav4jvm/DavCalendarTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/DavCalendarTest.kt index 86bc9b8..12bb9be 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/DavCalendarTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/DavCalendarTest.kt @@ -10,54 +10,59 @@ package at.bitfire.dav4jvm -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.OkHttpClient -import org.junit.After +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.http.headersOf +import io.ktor.http.withCharset +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Test import java.time.Instant class DavCalendarTest { - private val httpClient = OkHttpClient.Builder() - .followRedirects(false) - .build() - private val mockServer = MockWebServer() - - @Before - fun startServer() { - mockServer.start() - } - - @After - fun stopServer() { - mockServer.close() - } - @Test fun calendarQuery_formatStartEnd() { - val cal = DavCalendar(httpClient, mockServer.url("/")) - mockServer.enqueue(MockResponse.Builder().code(207).body("").build()) - cal.calendarQuery("VEVENT", - start = Instant.ofEpochSecond(784111777), - end = Instant.ofEpochSecond(1689324577)) { _, _ -> } - val rq = mockServer.takeRequest() - assertEquals("" + - "" + - "" + + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode.MultiStatus, // 207 + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Xml.withCharset(Charsets.UTF_8).toString()) + ) + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val cal = DavCalendar(httpClient, Url("/")) + + runBlocking { + cal.calendarQuery( + "VEVENT", + start = Instant.ofEpochSecond(784111777), + end = Instant.ofEpochSecond(1689324577) + ) { _, _ -> } + + assertEquals( + "" + + "" + + "" + "" + - "" + - "" + + "" + + "" + "" + - "" + - "" + - "" + + "" + + "" + "" + - "" + - "", rq.body?.utf8()) + "" + + "" + + "", + mockEngine.requestHistory.last().body.toByteArray().toString(Charsets.UTF_8) + ) + } } - } \ No newline at end of file diff --git a/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt index c88b9db..792a7ae 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt @@ -14,32 +14,30 @@ import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.property.webdav.GetETag import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV import at.bitfire.dav4jvm.property.webdav.SyncToken -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.RequestBody.Companion.toRequestBody -import org.junit.After -import org.junit.Assert.* -import org.junit.Before +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.statement.request +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.headersOf +import io.ktor.http.takeFrom +import io.ktor.http.withCharset +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test -import java.net.HttpURLConnection class DavCollectionTest { private val sampleText = "SAMPLE RESPONSE" - - private val httpClient = OkHttpClient.Builder() - .followRedirects(false) - .build() - private val mockServer = MockWebServer() - private fun sampleUrl() = mockServer.url("/dav/") - - @Before - fun startServer() = mockServer.start() - - @After - fun stopServer() = mockServer.close() + private val sampleUrl = Url("http://127.0.0.1/dav/") /** @@ -47,19 +45,15 @@ class DavCollectionTest { */ @Test fun testInitialSyncCollectionReport() { - val url = sampleUrl() - val collection = DavCollection(httpClient, url) - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "text/xml; charset=\"utf-8\"") - .body( + val mockEngine = MockEngine { request -> + respond( + content = "\n" + " \n" + " \n" + " ${sampleUrl()}test.doc\n" + + " >${sampleUrl}test.doc\n" + " \n" + " \n" + " \"00001-abcd1\"\n" + @@ -72,7 +66,7 @@ class DavCollectionTest { " \n" + " \n" + " ${sampleUrl()}vcard.vcf\n" + + " >${sampleUrl}vcard.vcf\n" + " \n" + " \n" + " \"00002-abcd1\"\n" + @@ -88,7 +82,7 @@ class DavCollectionTest { " \n" + " \n" + " ${sampleUrl()}calendar.ics\n" + + " >${sampleUrl}calendar.ics\n" + " \n" + " \n" + " \"00003-abcd1\"\n" + @@ -103,14 +97,22 @@ class DavCollectionTest { " \n" + " \n" + " http://example.com/ns/sync/1234\n" + - " " - ) - .build() - ) + " ", + status = HttpStatusCode.MultiStatus, + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Xml.withCharset(Charsets.UTF_8).toString()) + ) + } + val httpClient = HttpClient(mockEngine) + + val url = sampleUrl + val collection = DavCollection(httpClient, url) + + runBlocking { + var nrCalled = 0 val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation -> when (response.href) { - url.resolve("/dav/test.doc") -> { + URLBuilder(url).takeFrom("/dav/test.doc").build() -> { assertTrue(response.isSuccess()) assertEquals(Response.HrefRelation.MEMBER, relation) val eTag = response[GetETag::class.java] @@ -118,7 +120,7 @@ class DavCollectionTest { assertFalse(eTag.weak) nrCalled++ } - url.resolve("/dav/vcard.vcf") -> { + URLBuilder(url).takeFrom("/dav/vcard.vcf").build() -> { assertTrue(response.isSuccess()) assertEquals(Response.HrefRelation.MEMBER, relation) val eTag = response[GetETag::class.java] @@ -126,7 +128,7 @@ class DavCollectionTest { assertFalse(eTag.weak) nrCalled++ } - url.resolve("/dav/calendar.ics") -> { + URLBuilder(url).takeFrom("/dav/calendar.ics").build() -> { assertTrue(response.isSuccess()) assertEquals(Response.HrefRelation.MEMBER, relation) val eTag = response[GetETag::class.java] @@ -138,6 +140,7 @@ class DavCollectionTest { } assertEquals(3, nrCalled) assertEquals("http://example.com/ns/sync/1234", result.filterIsInstance().first().token) + } } /** @@ -145,83 +148,88 @@ class DavCollectionTest { */ @Test fun testInitialSyncCollectionReportWithTruncation() { - val url = sampleUrl() - val collection = DavCollection(httpClient, url) - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "text/xml; charset=\"utf-8\"") - .body( - "\n" + - " \n" + - " \n" + - " ${sampleUrl()}test.doc\n" + - " \n" + - " \n" + - " \"00001-abcd1\"\n" + - " \n" + - " HTTP/1.1 200 OK\n" + - " \n" + - " \n" + - " \n" + - " ${sampleUrl()}vcard.vcf\n" + - " \n" + - " \n" + - " \"00002-abcd1\"\n" + - " \n" + - " HTTP/1.1 200 OK\n" + - " \n" + - " \n" + - " \n" + - " ${sampleUrl()}removed.txt\n" + - " HTTP/1.1 404 Not Found\n" + - " " + - " \n" + - " ${sampleUrl()}\n" + - " HTTP/1.1 507 Insufficient Storage\n" + - " \n" + - " " + - " http://example.com/ns/sync/1233\n" + - " " - ) - .build() - ) + val mockEngine = MockEngine { request -> + respond( + content = "\n" + + " \n" + + " \n" + + " ${sampleUrl}test.doc\n" + + " \n" + + " \n" + + " \"00001-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl}vcard.vcf\n" + + " \n" + + " \n" + + " \"00002-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl}removed.txt\n" + + " HTTP/1.1 404 Not Found\n" + + " " + + " \n" + + " ${sampleUrl}\n" + + " HTTP/1.1 507 Insufficient Storage\n" + + " \n" + + " " + + " http://example.com/ns/sync/1233\n" + + " ", + status = HttpStatusCode.MultiStatus, // 207 + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Xml.withCharset(Charsets.UTF_8).toString()) + ) + } + val httpClient = HttpClient(mockEngine) + val collection = DavCollection(httpClient, sampleUrl) + var nrCalled = 0 - val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation -> - when (response.href) { - url.resolve("/dav/test.doc") -> { - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.MEMBER, relation) - val eTag = response[GetETag::class.java] - assertEquals("00001-abcd1", eTag?.eTag) - assertTrue(eTag?.weak == false) - nrCalled++ - } - url.resolve("/dav/vcard.vcf") -> { - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.MEMBER, relation) - val eTag = response[GetETag::class.java] - assertEquals("00002-abcd1", eTag?.eTag) - assertTrue(eTag?.weak == false) - nrCalled++ - } - url.resolve("/dav/removed.txt") -> { - assertFalse(response.isSuccess()) - assertEquals(404, response.status?.code) - assertEquals(Response.HrefRelation.MEMBER, relation) - nrCalled++ - } - url.resolve("/dav/") -> { - assertFalse(response.isSuccess()) - assertEquals(507, response.status?.code) - assertEquals(Response.HrefRelation.SELF, relation) - nrCalled++ + + runBlocking { + val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation -> + when (response.href) { + URLBuilder(sampleUrl).takeFrom("/dav/test.doc").build() -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + val eTag = response[GetETag::class.java] + assertEquals("00001-abcd1", eTag?.eTag) + assertTrue(eTag?.weak == false) + nrCalled++ + } + + URLBuilder(sampleUrl).takeFrom("/dav/vcard.vcf").build() -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + val eTag = response[GetETag::class.java] + assertEquals("00002-abcd1", eTag?.eTag) + assertTrue(eTag?.weak == false) + nrCalled++ + } + + URLBuilder(sampleUrl).takeFrom("/dav/removed.txt").build() -> { + assertFalse(response.isSuccess()) + assertEquals(404, response.status?.value) + assertEquals(Response.HrefRelation.MEMBER, relation) + nrCalled++ + } + + URLBuilder(sampleUrl).takeFrom("/dav/").build() -> { + assertFalse(response.isSuccess()) + assertEquals(507, response.status?.value) + assertEquals(Response.HrefRelation.SELF, relation) + nrCalled++ + } } } + assertEquals("http://example.com/ns/sync/1233", result.filterIsInstance().first().token) + assertEquals(4, nrCalled) } - assertEquals("http://example.com/ns/sync/1233", result.filterIsInstance().first().token) - assertEquals(4, nrCalled) } /** @@ -229,46 +237,55 @@ class DavCollectionTest { */ @Test fun testSyncCollectionReportWithUnsupportedLimit() { - val url = sampleUrl() - val collection = DavCollection(httpClient, url) - mockServer.enqueue( - MockResponse.Builder() - .code(507) - .setHeader("Content-Type", "text/xml; charset=\"utf-8\"") - .body( - "\n" + - " \n" + - " \n" + - " " - ) - .build() - ) + val mockEngine = MockEngine { request -> + respond( + content = "\n" + + " \n" + + " \n" + + " ", + status = HttpStatusCode.InsufficientStorage, // 507 @Ricki, does 507 really make sense here? + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Xml.withCharset(Charsets.UTF_8).toString()) + ) + } + val httpClient = HttpClient(mockEngine) + val collection = DavCollection(httpClient, sampleUrl) - try { - collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME) { _, _ -> } - fail("Expected HttpException") - } catch (e: HttpException) { - assertEquals(507, e.code) - assertTrue(e.errors.any { it.name == Property.Name(NS_WEBDAV, "number-of-matches-within-limits") }) - assertEquals(1, e.errors.size) + runBlocking { + try { + collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME) { _, _ -> } + fail("Expected HttpException") + } catch (e: HttpException) { + assertEquals(HttpStatusCode.InsufficientStorage.value, e.code) + assertTrue(e.errors.any { it.name == Property.Name(NS_WEBDAV, "number-of-matches-within-limits") }) + assertEquals(1, e.errors.size) + } } } @Test fun testPost() { - val url = sampleUrl() - val dav = DavCollection(httpClient, url) - // 201 Created - mockServer.enqueue(MockResponse.Builder().code(HttpURLConnection.HTTP_CREATED).build()) + val mockEngine = MockEngine { request -> + respond( + content = sampleText, + status = HttpStatusCode.Created, // 201 Created + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + val dav = DavCollection(httpClient, sampleUrl) + var called = false - dav.post(sampleText.toRequestBody("text/plain".toMediaType())) { response -> - assertEquals("POST", mockServer.takeRequest().method) - assertEquals(response.request.url, dav.location) - called = true + runBlocking { + dav.post(sampleText) { response -> + assertEquals(HttpMethod.Post, response.request.method) + assertEquals(HttpStatusCode.Created, response.status) + assertEquals(response.request.url, dav.location) + called = true + } + assertTrue(called) } - assertTrue(called) } } \ No newline at end of file diff --git a/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt index b03d8a4..95ec800 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt @@ -14,951 +14,1114 @@ import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.exception.PreconditionFailedException import at.bitfire.dav4jvm.property.webdav.DisplayName -import at.bitfire.dav4jvm.property.webdav.GetContentType import at.bitfire.dav4jvm.property.webdav.GetETag import at.bitfire.dav4jvm.property.webdav.ResourceType -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.After +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +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.request.get +import io.ktor.client.request.prepareRequest +import io.ktor.client.statement.bodyAsText +import io.ktor.client.statement.request +import io.ktor.http.ContentType +import io.ktor.http.HeadersBuilder +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.content.TextContent +import io.ktor.http.contentType +import io.ktor.http.fullPath +import io.ktor.http.headersOf +import io.ktor.http.takeFrom +import io.ktor.http.withCharset +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail -import org.junit.Before import org.junit.Test -import java.net.HttpURLConnection class DavResourceTest { private val sampleText = "SAMPLE RESPONSE" + val sampleUrl = Url("https://127.0.0.1/dav/") + val sampleDestination = URLBuilder(sampleUrl).takeFrom("test").build() - private val httpClient = OkHttpClient.Builder() - .followRedirects(false) - .build() - private val mockServer = MockWebServer() + @Test + fun `Copy POSITIVE no preconditions, 201 Created, resulted in the creation of a new resource`() { + val mockEngine = MockEngine { request -> + respond( + content = sampleText, + status = HttpStatusCode.Created, // 201 Created + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + var called = false - @Before - fun startServer() { - mockServer.start() - } + runBlocking { + DavResource(httpClient, sampleUrl).let { dav -> + dav.copy(sampleDestination, false) { + called = true + } + assertTrue(called) + } - @After - fun stopServer() { - mockServer.close() + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.parse("COPY"), rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertEquals(sampleDestination.toString(), rq.headers[HttpHeaders.Destination]) + assertEquals("F", rq.headers[HttpHeaders.Overwrite]) + } } - private fun sampleUrl() = mockServer.url("/dav/") + @Test + fun `Copy POSITIVE no preconditions, 204 No content, resource successfully copied to a preexisting destination resource`() { + val mockEngine = MockEngine { request -> + respond( + content = sampleText, + status = HttpStatusCode.NoContent, // 204 No content + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + var called = false + DavResource(httpClient, sampleUrl).let { dav -> + dav.copy(sampleDestination, true) { + called = true + } + assertTrue(called) + } + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.parse("COPY"), rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertEquals(sampleDestination.toString(), rq.headers[HttpHeaders.Destination]) + assertNull(rq.headers[HttpHeaders.Overwrite]) + } + } @Test - fun testCopy() { - val url = sampleUrl() - val destination = url.resolve("test")!! + fun `Copy NEGATIVE 207 multi-status eg errors on some of resources affected by the COPY prevented the operation from taking place`() { + val mockEngine = MockEngine { request -> + respond( + content = sampleText, + status = HttpStatusCode.MultiStatus, // 207 multi-status + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + var called = false - /* POSITIVE TEST CASES */ + runBlocking { + try { + called = false + DavResource(httpClient, sampleUrl).let { dav -> + dav.copy(sampleDestination, false) { called = true } + fail("Expected HttpException") + } + } catch (_: HttpException) { + assertFalse(called) + } + } + } - // no preconditions, 201 Created, resulted in the creation of a new resource - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_CREATED) - .build()) + @Test + fun `Delete only eTag POSITIVE TEST CASES precondition If-Match 200 OK`() { + + val mockEngine = MockEngine { request -> + respond(sampleText, HttpStatusCode.NoContent) // 204 No Content + } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) var called = false - DavResource(httpClient, url).let { dav -> - dav.copy(destination, false) { - called = true - } + + runBlocking { + dav.delete { called = true } assertTrue(called) + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.Delete, rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertNull(rq.headers[HttpHeaders.IfMatch]) + } + } - var rq = mockServer.takeRequest() - assertEquals("COPY", rq.method) - assertEquals(url, rq.url) - assertEquals(destination.toString(), rq.headers["Destination"]) - assertEquals("F", rq.headers["Overwrite"]) - - // no preconditions, 204 No content, resource successfully copied to a preexisting - // destination resource - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_NO_CONTENT) - .build()) - called = false - DavResource(httpClient, url).let { dav -> - dav.copy(destination, true) { - called = true - } - assertTrue(called) + @Test + fun `Delete eTag and schedule Tag POSITIVE TEST CASES precondition If-Match 200 OK`() { + + val mockEngine = MockEngine { request -> + respondOk(content = sampleText) } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + var called = false - rq = mockServer.takeRequest() - assertEquals("COPY", rq.method) - assertEquals(url, rq.url) - assertEquals(destination.toString(), rq.headers["Destination"]) - assertNull(rq.headers["Overwrite"]) + runBlocking { + dav.delete("DeleteOnlyThisETag", "DeleteOnlyThisScheduleTag") { called = true } + assertTrue(called) - /* NEGATIVE TEST CASES */ + val rq = mockEngine.requestHistory.last() + assertEquals("\"DeleteOnlyThisETag\"", rq.headers[HttpHeaders.IfMatch]) + assertEquals("\"DeleteOnlyThisScheduleTag\"", rq.headers[HttpHeaders.IfScheduleTagMatch]) + } + } - // 207 multi-status (e.g. errors on some of resources affected by - // the COPY prevented the operation from taking place) - mockServer.enqueue(MockResponse.Builder() - .code(207) - .build()) - try { - called = false - DavResource(httpClient, url).let { dav -> - dav.copy(destination, false) { called = true } - fail("Expected HttpException") + @Test + fun `Delete POSITIVE TEST CASES precondition If-Match 302 Moved Temporarily`() { + + var numResponses = 0 + val mockEngine = MockEngine { request -> + numResponses+=1 + when(numResponses) { + 1 -> respondRedirect("/new-location") //307 TemporaryRedirect + else -> respondOk() } - } catch(_: HttpException) { - assertFalse(called) + } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + var called = false + + runBlocking { + dav.delete(null) { + called = true + } + assertTrue(called) } } - @Test - fun testDelete() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - /* POSITIVE TEST CASES */ + @Test + fun `Delete NEGATIVE TEST CASES precondition If-Match 207 multi-status`() { - // no preconditions, 204 No Content - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_NO_CONTENT) - .build()) - var called = false - dav.delete { - called = true + val mockEngine = MockEngine { request -> + respondError(HttpStatusCode.MultiStatus) } - assertTrue(called) + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + var called = false - var rq = mockServer.takeRequest() - assertEquals("DELETE", rq.method) - assertEquals(url, rq.url) - assertNull(rq.headers["If-Match"]) - - // precondition: If-Match / If-Schedule-Tag-Match, 200 OK - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .body("Resource has been deleted.") - .build()) - called = false - dav.delete("DeleteOnlyThisETag", "DeleteOnlyThisScheduleTag") { - called = true + runBlocking { + try { + dav.delete(null) { called = true } + fail("Expected HttpException") + } catch(_: HttpException) { + assertFalse(called) + } } - assertTrue(called) + } - rq = mockServer.takeRequest() - assertEquals("\"DeleteOnlyThisETag\"", rq.headers["If-Match"]) - assertEquals("\"DeleteOnlyThisScheduleTag\"", rq.headers["If-Schedule-Tag-Match"]) - // 302 Moved Temporarily - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_MOVED_TEMP) - .setHeader("Location", "/new-location") - .build() - ) - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .build()) - called = false - dav.delete(null) { - called = true + @Test + fun `followRedirects 302 Found`() { + var numResponses = 0 + val mockEngine = MockEngine { request -> + numResponses+=1 + when(numResponses) { + 1 -> respond("New location!", HttpStatusCode.Found, headersOf(HttpHeaders.Location, "https://to.com/")) + else -> respond("", HttpStatusCode.NoContent, headersOf(HttpHeaders.Location, "https://to.com/")) + } } - assertTrue(called) + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + dav.followRedirects { + httpClient.get("https://from.com/") + }.let { response -> + assertEquals(HttpStatusCode.NoContent, response.status) + assertEquals(Url("https://to.com/"), dav.location) + } + } + } - /* NEGATIVE TEST CASES */ + @Test(expected = DavException::class) + fun `followRedirects Https To Http`() { + val mockEngine = MockEngine { request -> + respond("New location!", HttpStatusCode.Found, headersOf(HttpHeaders.Location, "http://to.com/")) + } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } - // 207 multi-status (e.g. single resource couldn't be deleted when DELETEing a collection) - mockServer.enqueue(MockResponse.Builder() - .code(207) - .build()) - try { - called = false - dav.delete(null) { called = true } - fail("Expected HttpException") - } catch(_: HttpException) { - assertFalse(called) + val dav = DavResource(httpClient, Url("https://from.com")) + runBlocking { + dav.followRedirects { + httpClient.get("https://from.com/") + } } } @Test - fun testFollowRedirects_302() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - var i = 0 - dav.followRedirects { - if (i++ == 0) - okhttp3.Response.Builder() - .protocol(Protocol.HTTP_1_1) - .code(302) - .message("Found") - .header("Location", "http://to.com/") - .request(Request.Builder() - .get() - .url("http://from.com/") - .build()) - .body("New location!".toResponseBody()) - .build() - else - okhttp3.Response.Builder() - .protocol(Protocol.HTTP_1_1) - .code(204) - .message("No Content") - .request(Request.Builder() - .get() - .url("http://to.com/") - .build()) - .build() - }.let { response -> - assertEquals(204, response.code) - assertEquals("http://to.com/".toHttpUrl(), dav.location) + fun `Get POSITIVE TEST CASES 200 OK`() { + val mockEngine = MockEngine { request -> + respond( + content = sampleText, + status = HttpStatusCode.OK, // 200 OK + headers = HeadersBuilder().apply { + append(HttpHeaders.ETag, "W/\"My Weak ETag\"") + append(HttpHeaders.ContentType, "application/x-test-result") + }.build() + ) } - } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + var called = false - @Test(expected = DavException::class) - fun testFollowRedirects_HttpsToHttp() { - val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) - dav.followRedirects { - okhttp3.Response.Builder() - .protocol(Protocol.HTTP_1_1) - .code(302) - .message("Found") - .header("Location", "http://to.com/") - .request(Request.Builder() - .get() - .url("https://from.com/") - .build()) - .body("New location!".toResponseBody()) - .build() + runBlocking { + dav.get(ContentType.Any.toString(), null) { response -> + called = true + runBlocking { assertEquals(sampleText, response.bodyAsText()) } + + val eTag = GetETag.fromResponse(response) + assertEquals("My Weak ETag", eTag!!.eTag) + assertTrue(eTag.weak) + assertEquals(ContentType.parse("application/x-test-result"), response.contentType()) + } + assertTrue(called) + + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.Get, rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertEquals(ContentType.Any.toString(), rq.headers[HttpHeaders.Accept]) } } + @Test - fun testGet() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) + fun `Get POSITIVE TEST CASES 302 Moved Temporarily + 200 OK`() { + var numResponses = 0 + val mockEngine = MockEngine { request -> + numResponses+=1 + when(numResponses) { + 1 -> respond("This resource was moved.", HttpStatusCode.TemporaryRedirect, headersOf(HttpHeaders.Location,"/target")) + else -> respond(sampleText, HttpStatusCode.OK, headersOf(HttpHeaders.ETag,"\"StrongETag\"")) + } + } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + var called = false - /* POSITIVE TEST CASES */ + runBlocking { - // 200 OK - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .setHeader("ETag", "W/\"My Weak ETag\"") - .setHeader("Content-Type", "application/x-test-result") - .body(sampleText) - .build()) - var called = false - dav.get("*/*", null) { response -> - called = true - assertEquals(sampleText, response.body.string()) + dav.get(ContentType.Any.toString(), null) { response -> + called = true + runBlocking { assertEquals(sampleText, response.bodyAsText()) } + val eTag = GetETag(response.headers[HttpHeaders.ETag]) + assertEquals("StrongETag", eTag.eTag) + assertFalse(eTag.weak) + } + assertTrue(called) - val eTag = GetETag.fromResponse(response) - assertEquals("My Weak ETag", eTag!!.eTag) - assertTrue(eTag.weak) - assertEquals("application/x-test-result".toMediaType(), GetContentType(response.body.contentType()!!).type) + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.Get, rq.method) + assertEquals("/target", rq.url.fullPath) } - assertTrue(called) + } - var rq = mockServer.takeRequest() - assertEquals("GET", rq.method) - assertEquals(url, rq.url) - assertEquals("*/*", rq.headers["Accept"]) - - // 302 Moved Temporarily + 200 OK - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_MOVED_TEMP) - .setHeader("Location", "/target") - .body("This resource was moved.") - .build()) - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .setHeader("ETag", "\"StrongETag\"") - .body(sampleText) - .build()) - called = false - dav.get("*/*", null) { response -> - called = true - assertEquals(sampleText, response.body.string()) - val eTag = GetETag(response.header("ETag")!!) - assertEquals("StrongETag", eTag.eTag) - assertFalse(eTag.weak) + + @Test + fun `Get POSITIVE TEST CASES 200 OK without ETag in response`() { + val mockEngine = MockEngine { request -> + respond(sampleText, HttpStatusCode.OK) } - assertTrue(called) + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + var called = false - mockServer.takeRequest() - rq = mockServer.takeRequest() - assertEquals("GET", rq.method) - assertEquals("/target", rq.url.encodedPath) - - // 200 OK without ETag in response - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .body(sampleText) - .build()) - called = false - dav.get("*/*", null) { response -> - called = true - assertNull(response.header("ETag")) + runBlocking { + dav.get(ContentType.Any.toString(), null) { response -> + called = true + assertNull(response.headers[HttpHeaders.ETag]) + } + assertTrue(called) } - assertTrue(called) } + @Test - fun testGetRange_Ok() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) + fun `GetRange Ok`() { + val mockEngine = MockEngine { request -> + respond("",HttpStatusCode.PartialContent) // 206 + } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) - mockServer.enqueue(MockResponse.Builder() - .code(HttpURLConnection.HTTP_PARTIAL) - .build()) var called = false - dav.getRange("*/*", 100, 342) { response -> - assertEquals("bytes=100-441", response.request.header("Range")) - called = true + runBlocking { + dav.getRange(ContentType.Any.toString(), 100, 342) { response -> + assertEquals("bytes=100-441", response.request.headers[HttpHeaders.Range]) + called = true + } + assertTrue(called) } - assertTrue(called) } @Test - fun testPost() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) + fun `Post POSITIVE TEST CASES 200 OK`() { + val mockEngine = MockEngine { request -> + respond( + content = sampleText, + status = HttpStatusCode.OK, // 200 OK + headers = HeadersBuilder().apply { + append(HttpHeaders.ContentType, "application/x-test-result") + append(HttpHeaders.ETag, "W/\"My Weak ETag\"") + }.build() + ) + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) /* POSITIVE TEST CASES */ // 200 OK - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .setHeader("ETag", "W/\"My Weak ETag\"") - .setHeader("Content-Type", "application/x-test-result") - .body(sampleText) - .build() - ) - var called = false - dav.post( - body = "body".toRequestBody("application/x-test-result".toMediaType()) - ) { response -> - called = true - assertEquals(sampleText, response.body.string()) + runBlocking { + var called = false + dav.post( + body = "body", + headers = HeadersBuilder().apply { append(HttpHeaders.ContentType, "application/x-test-result") }.build() + ) { response -> + called = true + runBlocking { assertEquals(sampleText, response.bodyAsText()) } - val eTag = GetETag.fromResponse(response) - assertEquals("My Weak ETag", eTag!!.eTag) - assertTrue(eTag.weak) - assertEquals("application/x-test-result".toMediaType(), GetContentType(response.body.contentType()!!).type) - } - assertTrue(called) + val eTag = GetETag.fromResponse(response) + assertEquals("My Weak ETag", eTag!!.eTag) + assertTrue(eTag.weak) + assertEquals(ContentType.parse("application/x-test-result"), response.contentType()) + } + assertTrue(called) - var rq = mockServer.takeRequest() - assertEquals("POST", rq.method) - assertEquals(url, rq.url) - assertTrue(rq.headers["Content-Type"]?.contains("application/x-test-result") == true) - assertEquals("body", rq.body?.utf8()) - - // 302 Moved Temporarily + 200 OK - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_MOVED_TEMP) - .setHeader("Location", "/target") - .body("This resource was moved.") - .build() - ) - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .setHeader("ETag", "\"StrongETag\"") - .body(sampleText) - .build() - ) - called = false - dav.post( - body = "body".toRequestBody("application/x-test-result".toMediaType()) - ) { response -> - called = true - assertEquals(sampleText, response.body.string()) - val eTag = GetETag(response.header("ETag")!!) - assertEquals("StrongETag", eTag.eTag) - assertFalse(eTag.weak) - } - assertTrue(called) + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.Post, rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertEquals(ContentType.parse("application/x-test-result"), rq.body.contentType) // TODO: Originally there was a check for the header, not the content type in the body, what is correct here? + assertEquals("body", (rq.body as TextContent).text) - mockServer.takeRequest() - rq = mockServer.takeRequest() - assertEquals("POST", rq.method) - assertEquals("/target", rq.url.encodedPath) - - // 200 OK without ETag in response - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .body(sampleText) - .build() - ) - called = false - dav.post( - body = "body".toRequestBody("application/x-test-result".toMediaType()) - ) { response -> - called = true - assertNull(response.header("ETag")) + /* + // 302 Moved Temporarily + 200 OK + called = false + dav.post( + body = "body", + headers = HeadersBuilder().apply { append(HttpHeaders.ContentType, ContentType.parse("application/x-test-result").toString()) }.build() + ) { response -> + called = true + runBlocking { assertEquals(sampleText, response.bodyAsText()) } + val eTag = GetETag(response.headers[HttpHeaders.ETag]) + assertEquals("StrongETag", eTag.eTag) + assertFalse(eTag.weak) + } + assertTrue(called) + + rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.Post, rq.method) + assertEquals("/target", rq.url.encodedPath) + + // 200 OK without ETag in response + called = false + dav.post( + body = "body", + ) { response -> + called = true + assertNull(response.headers[HttpHeaders.ETag]) + } + assertTrue(called) + + */ } - assertTrue(called) } @Test - fun testMove() { - val url = sampleUrl() - val destination = url.resolve("test")!! + fun `Post POSITIVE TEST CASES 302 Moved Temporarily + 200 OK`() { + var numResponses = 0 + val mockEngine = MockEngine { request -> + numResponses+=1 + when(numResponses) { + 1 -> respond("This resource was moved.", HttpStatusCode.TemporaryRedirect, headersOf(HttpHeaders.Location,"/target")) + else -> respond(sampleText, HttpStatusCode.OK, headersOf(HttpHeaders.ETag,"\"StrongETag\"")) + } + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + var called = false + dav.post( + body = "body", + headers = HeadersBuilder().apply { append(HttpHeaders.ContentType, ContentType.parse("application/x-test-result").toString()) }.build() + ) { response -> + called = true + runBlocking { assertEquals(sampleText, response.bodyAsText()) } + val eTag = GetETag(response.headers[HttpHeaders.ETag]) + assertEquals("StrongETag", eTag.eTag) + assertFalse(eTag.weak) + } + assertTrue(called) - /* POSITIVE TEST CASES */ + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.Post, rq.method) + assertEquals("/target", rq.url.encodedPath) + } + } - // no preconditions, 201 Created, new URL mapping at the destination - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_CREATED) - .build() - ) - var called = false - DavResource(httpClient, url).let { dav -> - dav.move(destination, false) { + @Test + fun `Post POSITIVE TEST CASES 200 OK without ETag in response`() { + val mockEngine = MockEngine { request -> + respond(sampleText, HttpStatusCode.OK) + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + var called = false + dav.post( + body = "body", + ) { response -> called = true + assertNull(response.headers[HttpHeaders.ETag]) } assertTrue(called) - assertEquals(destination, dav.location) } + } - var rq = mockServer.takeRequest() - assertEquals("MOVE", rq.method) - assertEquals(url, rq.url) - assertEquals(destination.toString(), rq.headers["Destination"]) - assertEquals("F", rq.headers["Overwrite"]) + @Test + fun `Move POSITIVE TEST CASES no preconditions, 201 Created, new URL mapping at the destination`() { + val mockEngine = MockEngine { request -> + respond("",HttpStatusCode.Created) // 201 Created + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val destination = URLBuilder(sampleUrl).takeFrom("test").build() + + runBlocking { + // no preconditions, 201 Created, new URL mapping at the destination + var called = false + DavResource(httpClient, sampleUrl).let { dav -> + dav.move(destination, false) { + called = true + } + assertTrue(called) + assertEquals(destination, dav.location) + } + + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.parse("MOVE"), rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertEquals(destination.toString(), rq.headers[HttpHeaders.Destination]) + assertEquals("F", rq.headers[HttpHeaders.Overwrite]) + } + } - // no preconditions, 204 No content, URL already mapped, overwrite - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_NO_CONTENT) - .build() - ) - called = false - DavResource(httpClient, url).let { dav -> - dav.move(destination, true) { + @Test + fun `Move POSITIVE TEST CASES no preconditions, 204 No content, URL already mapped, overwrite`() { + val mockEngine = MockEngine { request -> + respond("",HttpStatusCode.NoContent) // 204 No content + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val destination = URLBuilder(sampleUrl).takeFrom("test").build() + + runBlocking { + var called = false + DavResource(httpClient, sampleUrl).let { dav -> + dav.move(destination, true) { + called = true + } + assertTrue(called) + assertEquals(destination, dav.location) + } + + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.parse("MOVE"), rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertEquals(destination.toString(), rq.headers[HttpHeaders.Destination]) + assertNull(rq.headers[HttpHeaders.Overwrite]) + } + } + + @Test + fun `Move NEGATIVE TEST CASES no preconditions, 207 multi-status`() { + val mockEngine = MockEngine { request -> + respond("",HttpStatusCode.MultiStatus) // 207 Multi-Status + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val destination = URLBuilder(sampleUrl).takeFrom("test").build() + + runBlocking { + // 207 multi-status (e.g. errors on some of resources affected by + // the MOVE prevented the operation from taking place) + var called = false + try { + DavResource(httpClient, sampleUrl).let { dav -> + dav.move(destination, false) { called = true } + fail("Expected HttpException") + } + } catch (_: HttpException) { + assertFalse(called) + } + } + } + + @Test + fun `Options with capabilities`() { + val mockEngine = MockEngine { request -> + respond("",HttpStatusCode.OK, HeadersBuilder().apply { append("DAV", " 1, 2 ,3,hyperactive-access")}.build()) // 200 Ok + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + var called = false + dav.options { davCapabilities, _ -> + called = true + assertTrue(davCapabilities.any { it.contains("1") }) + assertTrue(davCapabilities.any { it.contains("2") }) + assertTrue(davCapabilities.any { it.contains("3") }) + assertTrue(davCapabilities.any { it.contains("hyperactive-access") }) + } + assertTrue(called) + } + } + + + @Test + fun `Options without capabilities`() { + val mockEngine = MockEngine { request -> + respondOk() // 200 OK + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + var called = false + dav.options { davCapabilities, _ -> called = true + assertTrue(davCapabilities.isEmpty()) } assertTrue(called) - assertEquals(destination, dav.location) } + } - rq = mockServer.takeRequest() - assertEquals("MOVE", rq.method) - assertEquals(url, rq.url) - assertEquals(destination.toString(), rq.headers["Destination"]) - assertNull(rq.headers["Overwrite"]) + @Test + fun `NEGATIVE TEST CASES Propfind And MultiStatus 500 Internal Server Error`() { + val mockEngine = MockEngine { request -> + respondError(HttpStatusCode.InternalServerError) // 500 + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + var called = false + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected HttpException") + } catch (e: HttpException) { + assertFalse(called) + } + + } + } - /* NEGATIVE TEST CASES */ + @Test + fun `NEGATIVE TEST CASES Propfind And MultiStatus 200 OK (instead of 207 Multi-Status)`() { + val mockEngine = MockEngine { request -> respondOk() } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + // * 200 OK (instead of 207 Multi-Status) + var called = false + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch (_: DavException) { + assertFalse(called) + } + } + } - // 207 multi-status (e.g. errors on some of resources affected by - // the MOVE prevented the operation from taking place) + @Test + fun `NEGATIVE TEST CASES Propfind And MultiStatus non-XML response`() { + val mockEngine = MockEngine { request -> + respond("", HttpStatusCode.MultiStatus, headersOf(HttpHeaders.ContentType, ContentType.Text.Html.toString())) // non-XML response + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + // * non-XML response + var called = false + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch (e: DavException) { + assertFalse(called) + } + } + } - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .build() - ) - try { - called = false - DavResource(httpClient, url).let { dav -> - dav.move(destination, false) { called = true } - fail("Expected HttpException") + @Test + fun `NEGATIVE TEST CASES Propfind And MultiStatus malformed XML response`() { + val mockEngine = MockEngine { request -> + respond("", HttpStatusCode.MultiStatus, headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString())) // * malformed XML response + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + // * malformed XML response + var called = false + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch (_: DavException) { + assertFalse(called) } - } catch(_: HttpException) { - assertFalse(called) } } + @Test - fun testOptions() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .setHeader("DAV", " 1, 2 ,3,hyperactive-access") - .build() - ) - var called = false - dav.options { davCapabilities, _ -> - called = true - assertTrue(davCapabilities.contains("1")) - assertTrue(davCapabilities.contains("2")) - assertTrue(davCapabilities.contains("3")) - assertTrue(davCapabilities.contains("hyperactive-access")) + fun `NEGATIVE TEST CASES Propfind And MultiStatus response without multistatus root element`() { + val mockEngine = MockEngine { request -> + respond("", HttpStatusCode.MultiStatus, headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString())) // * response without root element } - assertTrue(called) + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + // * response without root element + var called = false + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch (_: DavException) { + assertFalse(called) + } + } + } - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .build() - ) - called = false - dav.options { davCapabilities, _ -> - called = true - assertTrue(davCapabilities.isEmpty()) + + @Test + fun `NEGATIVE TEST CASES Propfind And MultiStatus multi-status response with invalid status in response`() { + val mockEngine = MockEngine { request -> + respond("" + + " " + + " /dav" + + " Invalid Status Line" + + " " + + "", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // * multi-status response with invalid in + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + // * multi-status response with invalid in + var called = false + dav.propfind(0, ResourceType.NAME) { response, relation -> + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals(HttpStatusCode.InternalServerError, response.status) + called = true + } + assertTrue(called) } - assertTrue(called) } @Test - fun testPropfindAndMultiStatus() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - - /*** NEGATIVE TESTS ***/ - - // test for non-multi-status responses: - // * 500 Internal Server Error - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_INTERNAL_ERROR) - .build() - ) - var called = false - try { - dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } - fail("Expected HttpException") - } catch(_: HttpException) { - assertFalse(called) - } - // * 200 OK (instead of 207 Multi-Status) - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_OK) - .build() - ) - try { - called = false - dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } - fail("Expected DavException") - } catch(_: DavException) { - assertFalse(called) - } - - // test for invalid multi-status responses: - // * non-XML response - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "text/html") - .body("") - .build() - ) - try { - called = false - dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } - fail("Expected DavException") - } catch(_: DavException) { - assertFalse(called) - } - - // * malformed XML response - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body("") - .build() - ) - try { - called = false - dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } - fail("Expected DavException") - } catch(_: DavException) { - assertFalse(called) - } - - // * response without root element - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body("") - .build() - ) - try { - called = false - dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } - fail("Expected DavException") - } catch(_: DavException) { - assertFalse(called) - } - - // * multi-status response with invalid in - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " /dav" + - " Invalid Status Line" + - " " + - "" - ) - .build() - ) - called = false - dav.propfind(0, ResourceType.NAME) { response, relation -> - assertEquals(Response.HrefRelation.SELF, relation) - assertEquals(500, response.status?.code) - called = true + fun `NEGATIVE TEST CASES Propfind And MultiStatus multi-status response with response-status element indicating failure`() { + val mockEngine = MockEngine { request -> + respond("" + + " " + + " /dav" + + " HTTP/1.1 403 Forbidden" + + " " + + "", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // * multi-status response with / element indicating failure } - assertTrue(called) + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + // * multi-status response with / element indicating failure + var called = false + dav.propfind(0, ResourceType.NAME) { response, relation -> + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals(HttpStatusCode.Forbidden, response.status) + called = true + } + assertTrue(called) + } + } - // * multi-status response with / element indicating failure - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " /dav" + - " HTTP/1.1 403 Forbidden" + - " " + - "" - ) - .build() - ) - called = false - dav.propfind(0, ResourceType.NAME) { response, relation -> - assertEquals(Response.HrefRelation.SELF, relation) - assertEquals(403, response.status?.code) - called = true + @Test + fun `NEGATIVE TEST CASES Propfind And MultiStatus multi-status response with invalid status in propstat`() { + val mockEngine = MockEngine { request -> + respond("" + + " " + + " /dav" + + " " + + " " + + " " + + " " + + " Invalid Status Line" + + " " + + " " + + "", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // * multi-status response with invalid in } - assertTrue(called) + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + // * multi-status response with invalid in + var called = false + dav.propfind(0, ResourceType.NAME) { response, relation -> + called = true + assertEquals(Response.HrefRelation.SELF, relation) + assertTrue(response.properties.filterIsInstance().isEmpty()) + } + assertTrue(called) - // * multi-status response with invalid in - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " /dav" + - " " + - " " + - " " + - " " + - " Invalid Status Line" + - " " + - " " + - "" - ) - .build() - ) - called = false - dav.propfind(0, ResourceType.NAME) { response, relation -> - called = true - assertEquals(Response.HrefRelation.SELF, relation) - assertTrue(response.properties.filterIsInstance().isEmpty()) } - assertTrue(called) + } - /*** POSITIVE TESTS ***/ + @Test + fun `NEGATIVE TEST CASES Propfind And MultiStatus multi-status response without response elements`() { + val mockEngine = MockEngine { request -> + respond("", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // multi-status response without elements + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) - // multi-status response without elements - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body("").build() - ) - dav.propfind(0, ResourceType.NAME) { _, _ -> - fail("Shouldn't be called") - } - - // multi-status response with / element indicating success - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " /dav" + - " HTTP/1.1 200 OK" + - " " + - "" - ) - .build() - ) - called = false - dav.propfind(0, ResourceType.NAME) { response, relation -> - called = true - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.SELF, relation) - assertEquals(0, response.properties.size) + runBlocking { + // multi-status response without elements + dav.propfind(0, ResourceType.NAME) { _, _ -> + fail("Shouldn't be called") + } } - assertTrue(called) + } - // multi-status response with / element - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " /dav" + - " " + - " " + - " " + - " My DAV Collection" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - "" - ) - .build() - ) - called = false - dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation -> - called = true - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.SELF, relation) - assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) + @Test + fun `POSITIVE TEST CASES Propfind And MultiStatus multi-status response with response-status element indicating success`() { + val mockEngine = MockEngine { request -> + respond( "" + + " " + + " /dav" + + " HTTP/1.1 200 OK" + + " " + + "", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // multi-status response with / element indicating success } - assertTrue(called) + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) - // multi-status response for collection with several members; incomplete (not all s listed) - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " " + url.toString() + "" + - " " + - " " + - " " + - " My DAV Collection" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - " " + - " /dav/subcollection" + - " " + - " " + - " " + - " A Subfolder" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - " " + - " /dav/uid@host:file" + - " " + - " " + - " Absolute path with @ and :" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - " " + - " relative-uid@host.file" + - " " + - " " + - " Relative path with @" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - " " + - " relative:colon.vcf" + - " " + - " " + - " Relative path with colon" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - " " + - " /something-very/else" + - " " + - " " + - " Not requested" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - "" - ).build() - ) - var nrCalled = 0 - dav.propfind(1, ResourceType.NAME, DisplayName.NAME) { response, relation -> - when (response.href) { - url.resolve("/dav/") -> { - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.SELF, relation) - assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) - assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) - nrCalled++ - } - url.resolve("/dav/subcollection/") -> { - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.MEMBER, relation) - assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) - assertEquals("A Subfolder", response[DisplayName::class.java]?.displayName) - nrCalled++ - } - url.resolve("/dav/uid@host:file") -> { - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.MEMBER, relation) - assertEquals("Absolute path with @ and :", response[DisplayName::class.java]?.displayName) - nrCalled++ - } - url.resolve("/dav/relative-uid@host.file") -> { - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.MEMBER, relation) - assertEquals("Relative path with @", response[DisplayName::class.java]?.displayName) - nrCalled++ - } - url.resolve("/dav/relative:colon.vcf") -> { - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.MEMBER, relation) - assertEquals("Relative path with colon", response[DisplayName::class.java]?.displayName) - nrCalled++ + runBlocking { + var called = false + // multi-status response with / element indicating success + dav.propfind(0, ResourceType.NAME) { response, relation -> + called = true + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals(0, response.properties.size) + } + assertTrue(called) + } + } + + + @Test + fun `POSITIVE TEST CASES Propfind And MultiStatus multi-status response with response-propstat element`() { + val mockEngine = MockEngine { request -> + respond( "" + + " " + + " /dav" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + "", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // multi-status response with / element + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + var called = false + dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation -> + called = true + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) + } + assertTrue(called) + } + } + + @Test + fun `POSITIVE TEST CASES Propfind And MultiStatus SPECIAL CASES multi-status response for collection with several members, incomplete (not all resourcetypes listed)`() { + val mockEngine = MockEngine { request -> + respond( "" + + " " + + " " + sampleUrl.toString() + "" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /dav/subcollection" + + " " + + " " + + " " + + " A Subfolder" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /dav/uid@host:file" + + " " + + " " + + " Absolute path with @ and :" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " relative-uid@host.file" + + " " + + " " + + " Relative path with @" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " relative:colon.vcf" + + " " + + " " + + " Relative path with colon" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /something-very/else" + + " " + + " " + + " Not requested" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + "", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // multi-status response for collection with several members; incomplete (not all s listed) + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + // multi-status response for collection with several members; incomplete (not all s listed) + var nrCalled = 0 + dav.propfind(1, ResourceType.NAME, DisplayName.NAME) { response, relation -> + when (response.href) { + URLBuilder(sampleUrl).takeFrom("/dav/").build() -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.SELF, relation) + assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) + assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + + URLBuilder(sampleUrl).takeFrom("/dav/subcollection/").build() -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) + assertEquals("A Subfolder", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + + URLBuilder(sampleUrl).takeFrom("/dav/uid@host:file").build() -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + assertEquals("Absolute path with @ and :", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + + URLBuilder(sampleUrl).takeFrom("/dav/relative-uid@host.file").build() -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + assertEquals("Relative path with @", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + + URLBuilder(sampleUrl).takeFrom("/dav/relative:colon.vcf").build() -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + assertEquals("Relative path with colon", response[DisplayName::class.java]?.displayName) + nrCalled++ + } } } + assertEquals(4, nrCalled) } - assertEquals(4, nrCalled) - - - /*** SPECIAL CASES ***/ - - // same property is sent as 200 OK and 404 Not Found in same (seen in iCloud) - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " " + url.toString() + "" + - " " + - " " + - " " + - " My DAV Collection" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - " " + - " " + - " " + - " HTTP/1.1 404 Not Found" + - " " + - " " + - "" - ).build() - ) - called = false - dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation -> - called = true - assertTrue(response.isSuccess()) - assertEquals(Response.HrefRelation.SELF, relation) - assertEquals(url.resolve("/dav/"), response.href) - assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) - assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) + } + + @Test + fun `POSITIVE TEST CASES Propfind And MultiStatus SPECIAL CASES same property is sent as 200 OK and 404 Not Found in same response (seen in iCloud)`() { + val mockEngine = MockEngine { request -> + respond( "" + + " " + + " " + sampleUrl.toString() + "" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " " + + " " + + " HTTP/1.1 404 Not Found" + + " " + + " " + + "", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // same property is sent as 200 OK and 404 Not Found in same (seen in iCloud) } - assertTrue(called) + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) - // multi-status response with that doesn't contain (=> assume 200 OK) - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " /dav" + - " " + - " " + - " Without Status" + - " " + - " " + - " " + - "" - ).build() - ) - called = false - dav.propfind(0, DisplayName.NAME) { response, _ -> - called = true - assertEquals(200, response.propstat.first().status.code) - assertEquals("Without Status", response[DisplayName::class.java]?.displayName) + runBlocking { + var called = false + dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation -> + called = true + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals(URLBuilder(sampleUrl).takeFrom("/dav/").build(), response.href) + assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) + assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) + } + assertTrue(called) } - assertTrue(called) } + @Test - fun testProppatch() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) + fun `POSITIVE TEST CASES Propfind And MultiStatus SPECIAL CASES multi-status response with propstat that doesn't contain status, assume 200 OK`() { + val mockEngine = MockEngine { request -> + respond( "" + + " " + + " /dav" + + " " + + " " + + " Without Status" + + " " + + " " + + " " + + "", + HttpStatusCode.MultiStatus, + headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) // multi-status response with that doesn't contain (=> assume 200 OK) + } + val httpClient = HttpClient(mockEngine) { followRedirects = false } + val dav = DavResource(httpClient, sampleUrl) + runBlocking { + /*** SPECIAL CASES ***/ + + // multi-status response with that doesn't contain (=> assume 200 OK) + var called = false + dav.propfind(0, DisplayName.NAME) { response, _ -> + called = true + assertEquals(200, response.propstat.first().status.value) + assertEquals("Without Status", response[DisplayName::class.java]?.displayName) + } + assertTrue(called) + } + } + + @Test + fun `Proppatch`() { // multi-status response with / elements - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " /dav" + - " " + - " " + - " Some Value" + - " " + - " HTTP/1.1 200 OK" + - " " + - " " + - " " + - " " + - " " + - " HTTP/1.1 404 Not Found" + - " " + - " " + - "" - ) - .build() - ) + val mockEngine = MockEngine { request -> + respond( + content = "" + + " " + + " /dav" + + " " + + " " + + " Some Value" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " " + + " " + + " HTTP/1.1 404 Not Found" + + " " + + " " + + "", + status = HttpStatusCode.MultiStatus, // 207 + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) + } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) var called = false - dav.proppatch( - setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")), - removeProperties = listOf(Property.Name("sample", "removeThis")) - ) { _, hrefRelation -> - called = true - assertEquals(Response.HrefRelation.SELF, hrefRelation) + runBlocking { + dav.proppatch( + setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")), + removeProperties = listOf(Property.Name("sample", "removeThis")) + ) { _, hrefRelation -> + called = true + assertEquals(Response.HrefRelation.SELF, hrefRelation) + } + assertTrue(called) } - assertTrue(called) } + @Test - fun testProppatch_createProppatchXml() { + fun `Proppatch createProppatchXml`() { val xml = DavResource.createProppatchXml( setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")), removeProperties = listOf(Property.Name("sample", "removeThis")) @@ -971,21 +1134,24 @@ class DavResourceTest { } @Test - fun testPut() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) + fun `Put POSITIVE TEST CASES no preconditions, 201 Created`() { + val mockEngine = MockEngine { request -> + respond( + content = " ", + status = HttpStatusCode.Created, // 201 Created + headers = headersOf(HttpHeaders.ETag, "W/\"Weak PUT ETag\"") + ) + } - /* POSITIVE TEST CASES */ + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) - // no preconditions, 201 Created - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_CREATED) - .setHeader("ETag", "W/\"Weak PUT ETag\"") - .build() - ) var called = false - dav.put(sampleText.toRequestBody("text/plain".toMediaType())) { response -> + runBlocking { + dav.put( + body = sampleText, + headers = HeadersBuilder().apply { append(HttpHeaders.ContentType, "text/plain") }.build() + ) { response -> called = true val eTag = GetETag.fromResponse(response)!! assertEquals("Weak PUT ETag", eTag.eTag) @@ -994,171 +1160,234 @@ class DavResourceTest { } assertTrue(called) - var rq = mockServer.takeRequest() - assertEquals("PUT", rq.method) - assertEquals(url, rq.url) - assertNull(rq.headers["If-Match"]) - assertNull(rq.headers["If-None-Match"]) - - // precondition: If-None-Match, 301 Moved Permanently + 204 No Content, no ETag in response - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_MOVED_PERM) - .setHeader("Location", "/target").build() - ) - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_NO_CONTENT).build() - ) - called = false - dav.put(sampleText.toRequestBody("text/plain".toMediaType()), ifNoneMatch = true) { response -> + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.Put, rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertNull(rq.headers[HttpHeaders.IfMatch]) + assertNull(rq.headers[HttpHeaders.IfNoneMatch]) + } + } + + @Test + fun `Put POSITIVE TEST CASES precondition If-None-Match, 301 Moved Permanently + 204 No Content, no ETag in response`() { + var numberOfResponse = 0 + val mockEngine = MockEngine { request -> + numberOfResponse+=1 + when(numberOfResponse) { + 1 -> respond( + content = "", + status = HttpStatusCode.MovedPermanently, // 301 Moved Permanently + headers = headersOf(HttpHeaders.Location, "/target") + ) + else -> respond("", HttpStatusCode.NoContent) + } + } + + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + + var called = false + runBlocking { + dav.put(sampleText, headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()), ifNoneMatch = true) { response -> called = true - assertEquals(url.resolve("/target"), response.request.url) + assertEquals(URLBuilder(sampleUrl).takeFrom("/target").build(), response.request.url) val eTag = GetETag.fromResponse(response) assertNull("Weak PUT ETag", eTag?.eTag) assertNull(eTag?.weak) } assertTrue(called) - mockServer.takeRequest() - rq = mockServer.takeRequest() - assertEquals("PUT", rq.method) - assertEquals("*", rq.headers["If-None-Match"]) - - // precondition: If-Match, 412 Precondition Failed - mockServer.enqueue( - MockResponse.Builder() - .code(HttpURLConnection.HTTP_PRECON_FAILED) - .build() - ) - called = false - try { - dav.put(sampleText.toRequestBody("text/plain".toMediaType()), "ExistingETag") { - called = true - } - fail("Expected PreconditionFailedException") - } catch(_: PreconditionFailedException) {} - assertFalse(called) - rq = mockServer.takeRequest() - assertEquals("\"ExistingETag\"", rq.headers["If-Match"]) - assertNull(rq.headers["If-None-Match"]) + val rq = mockEngine.requestHistory.last() + assertEquals(HttpMethod.Put, rq.method) + assertEquals("*", rq.headers[HttpHeaders.IfNoneMatch]) + } } @Test - fun testSearch() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - - mockServer.enqueue( - MockResponse.Builder() - .code(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .body( - "" + - " " + - " /dav" + - " " + - " " + - " Found something" + - " " + - " " + - " " + - "" - ).build() - ) + fun `Put NEGATIVE TEST CASES precondition If-Match, 412 Precondition Failed`() { + val mockEngine = MockEngine { request -> + respond("", HttpStatusCode.PreconditionFailed) + } + + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + var called = false - dav.search("") { response, hrefRelation -> - assertEquals(Response.HrefRelation.SELF, hrefRelation) - assertEquals("Found something", response[DisplayName::class.java]?.displayName) - called = true + runBlocking { + try { + dav.put(sampleText, headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()), "ExistingETag") { + called = true + } + fail("Expected PreconditionFailedException") + } catch(_: PreconditionFailedException) {} + assertFalse(called) + val rq = mockEngine.requestHistory.last() + assertEquals("\"ExistingETag\"", rq.headers[HttpHeaders.IfMatch]) + assertNull(rq.headers[HttpHeaders.IfNoneMatch]) } - assertTrue(called) + } + + @Test + fun `Search`() { + val mockEngine = MockEngine { request -> + respond( + content = "" + + " " + + " /dav" + + " " + + " " + + " Found something" + + " " + + " " + + " " + + "", + status = HttpStatusCode.MultiStatus, // 207 Multi-Status + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8).toString()) + ) + } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, sampleUrl) + var called = false - val rq = mockServer.takeRequest() - assertEquals("SEARCH", rq.method) - assertEquals(url, rq.url) - assertEquals("", rq.body?.utf8()) + runBlocking { + dav.search("") { response, hrefRelation -> + assertEquals(Response.HrefRelation.SELF, hrefRelation) + assertEquals("Found something", response[DisplayName::class.java]?.displayName) + called = true + } + assertTrue(called) + + val rq = mockEngine.requestHistory.last() + val requestBodyText = rq.body as TextContent + + assertEquals(HttpMethod.parse("SEARCH"), rq.method) + assertEquals(sampleUrl.encodedPath, rq.url.encodedPath) + assertEquals("", requestBodyText.text) + } } /** test helpers **/ @Test - fun testAssertMultiStatus_EmptyBody_NoXML() { - val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) - dav.assertMultiStatus(okhttp3.Response.Builder() - .request(Request.Builder().url(dav.location).build()) - .protocol(Protocol.HTTP_1_1) - .code(207).message("Multi-Status") - .build()) + fun `AssertMultiStatus EmptyBody NoXML`() { + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode.MultiStatus // 207 Multi-Status + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val dav = DavResource(httpClient, Url("https://from.com")) + dav.assertMultiStatus(httpClient.prepareRequest(dav.location).execute()) + } } @Test - fun testAssertMultiStatus_EmptyBody_XML() { - val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) - dav.assertMultiStatus(okhttp3.Response.Builder() - .request(Request.Builder().url(dav.location).build()) - .protocol(Protocol.HTTP_1_1) - .code(207).message("Multi-Status") - .addHeader("Content-Type", "text/xml") - .build()) + fun `AssertMultiStatus EmptyBody XML`() { + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode.MultiStatus, // 207 Multi-Status + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Xml.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val dav = DavResource(httpClient, Url("https://from.com")) + dav.assertMultiStatus(httpClient.prepareRequest(dav.location).execute()) + } } @Test - fun testAssertMultiStatus_NonXML_ButContentIsXML() { - val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) - dav.assertMultiStatus(okhttp3.Response.Builder() - .request(Request.Builder().url(dav.location).build()) - .protocol(Protocol.HTTP_1_1) - .code(207).message("Multi-Status") - .addHeader("Content-Type", "application/octet-stream") - .body("".toResponseBody()) - .build()) + fun `AssertMultiStatus NonXML ButContentIsXML`() { + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode.MultiStatus, // 207 Multi-Status + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.OctetStream.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val dav = DavResource(httpClient, Url("https://from.com")) + dav.assertMultiStatus(httpClient.prepareRequest(dav.location).execute()) + } } @Test(expected = DavException::class) - fun testAssertMultiStatus_NonXML_ReallyNotXML() { - val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) - dav.assertMultiStatus(okhttp3.Response.Builder() - .request(Request.Builder().url(dav.location).build()) - .protocol(Protocol.HTTP_1_1) - .code(207).message("Multi-Status") - .body("Some error occurred".toResponseBody("text/plain".toMediaType())) - .build()) + fun `AssertMultiStatus NonXML Really Not XML`() { + val mockEngine = MockEngine { request -> + respond( + content = "Some error occurred", + status = HttpStatusCode.MultiStatus, // 207 Multi-Status + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val dav = DavResource(httpClient, Url("https://from.com")) + dav.assertMultiStatus(httpClient.prepareRequest(dav.location).execute()) + } + } @Test(expected = DavException::class) - fun testAssertMultiStatus_Not207() { - val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) - dav.assertMultiStatus(okhttp3.Response.Builder() - .request(Request.Builder().url(dav.location).build()) - .protocol(Protocol.HTTP_1_1) - .code(403).message("Multi-Status") - .addHeader("Content-Type", "application/xml") - .body("".toResponseBody()) - .build()) + fun `AssertMultiStatus Not 207`() { + + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode(403, "Multi-Status"), // 207 Multi-Status + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val dav = DavResource(httpClient, Url("https://from.com")) + dav.assertMultiStatus(httpClient.prepareRequest(dav.location).execute()) + } + } @Test - fun testAssertMultiStatus_Ok_ApplicationXml() { - val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) - dav.assertMultiStatus(okhttp3.Response.Builder() - .request(Request.Builder().url(dav.location).build()) - .protocol(Protocol.HTTP_1_1) - .code(207).message("Multi-Status") - .body("".toResponseBody("application/xml".toMediaType())) - .build()) + fun `AssertMultiStatus Ok ApplicationXml`() { + + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode.MultiStatus, // 207 Multi-Status + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val dav = DavResource(httpClient, Url("https://from.com")) + dav.assertMultiStatus(httpClient.prepareRequest(dav.location).execute()) + } } @Test - fun testAssertMultiStatus_Ok_TextXml() { - val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) - dav.assertMultiStatus(okhttp3.Response.Builder() - .request(Request.Builder().url(dav.location).build()) - .protocol(Protocol.HTTP_1_1) - .code(207).message("Multi-Status") - .body("".toResponseBody("text/xml".toMediaType())) - .build()) + fun `AssertMultiStatus Ok TextXml`() { + + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode.MultiStatus, // 207 Multi-Status + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Xml.toString()) + ) + } + val httpClient = HttpClient(mockEngine) + val dav = DavResource(httpClient, Url("https://from.com")) + runBlocking { + dav.assertMultiStatus(httpClient.prepareRequest(dav.location).execute()) + } } - } diff --git a/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt index 9006827..b0c0cb9 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt @@ -10,7 +10,15 @@ package at.bitfire.dav4jvm -import okhttp3.HttpUrl.Companion.toHttpUrl +import at.bitfire.dav4jvm.HttpUtils.INVALID_STATUS +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.get +import io.ktor.http.HeadersBuilder +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -27,14 +35,30 @@ import java.util.logging.Logger class HttpUtilsTest { + val sampleUrl = Url("http://127.0.0.1") + private val multipleHeaderValues = " 1, 2 ,3,hyperactive-access" + private val singleHeaderValue = "other" + + private fun getMockEngineWithDAVHeaderValues(vararg headerValues: String): HttpClient { + val mockEngine = MockEngine { request -> + respond("",HttpStatusCode.OK, HeadersBuilder().apply { + headerValues.forEach { headerValue -> + append("DAV", headerValue) + } + }.build()) + } + return HttpClient(mockEngine) + } + + @Test fun fileName() { - assertEquals("", HttpUtils.fileName("https://example.com".toHttpUrl())) - assertEquals("", HttpUtils.fileName("https://example.com/".toHttpUrl())) - assertEquals("file1", HttpUtils.fileName("https://example.com/file1".toHttpUrl())) - assertEquals("dir1", HttpUtils.fileName("https://example.com/dir1/".toHttpUrl())) - assertEquals("file2", HttpUtils.fileName("https://example.com/dir1/file2".toHttpUrl())) - assertEquals("dir2", HttpUtils.fileName("https://example.com/dir1/dir2/".toHttpUrl())) + assertEquals("", HttpUtils.fileName(Url("https://example.com"))) + assertEquals("", HttpUtils.fileName(Url("https://example.com/"))) + assertEquals("file1", HttpUtils.fileName(Url("https://example.com/file1"))) + assertEquals("dir1", HttpUtils.fileName(Url("https://example.com/dir1/"))) + assertEquals("file2", HttpUtils.fileName(Url("https://example.com/dir1/file2"))) + assertEquals("dir2", HttpUtils.fileName(Url("https://example.com/dir1/dir2/"))) } @Test @@ -85,4 +109,216 @@ class HttpUtilsTest { assertEquals(Instant.ofEpochSecond(784111777), HttpUtils.parseDate("Sun Nov 6 08:49:37 1994")) } + + @Test + fun `listHeader with a single header value`() { + // Verify that when a header name has a single value, it's returned as a single-element array. + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues(singleHeaderValue) + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "DAV") + assertEquals(singleHeaderValue, headersArray[0]) + assertEquals(1, headersArray.size) + } + } + + @Test + fun `listHeader with multiple comma separated header values`() { + // Verify that when a header name has multiple comma-separated values, they are correctly split into an array. + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues(multipleHeaderValues) + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "DAV") + assertEquals(4, headersArray.size) + assertEquals("1", headersArray[0]) + assertEquals("2", headersArray[1]) + assertEquals("3", headersArray[2]) + assertEquals("hyperactive-access", headersArray[3]) + } + } + + @Test + fun `listHeader with multiple distinct header entries for the same name`() { + // Verify that if the same header name appears multiple times (e.g., 'Set-Cookie'), all values are joined by a comma and then split correctly. + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues(multipleHeaderValues, singleHeaderValue) + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "DAV") + assertEquals(5, headersArray.size) + assertEquals("1", headersArray[0]) + assertEquals("2", headersArray[1]) + assertEquals("3", headersArray[2]) + assertEquals("hyperactive-access", headersArray[3]) + assertEquals("other", headersArray[4]) + } + } + + @Test + fun `listHeader with a header name that does not exist`() { + // Verify that when a requested header name is not present in the response, an empty array is returned. + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues(multipleHeaderValues, singleHeaderValue) + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "other") + assertEquals(0, headersArray.size) + } + } + + @Test + fun `listHeader with an empty header value`() { + // Verify that if a header exists but its value is an empty string, an empty array is returned (due to filter { it.isNotEmpty() }). + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues("", "", "") + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "DAV") + assertEquals(0, headersArray.size) + } + } + + @Test + fun `listHeader with a header value containing only commas`() { + // Verify that if a header value consists only of commas (e.g., ',,,') an empty array is returned. + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues(",", ",", ",") + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "DAV") + assertEquals(0, headersArray.size) + } + } + + + @Test + fun `listHeader with header values that are themselves empty after splitting`() { + // Verify that if a header value is like 'value1,,value2', the empty string between commas is filtered out, resulting in ['value1', 'value2']. + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues(" ", singleHeaderValue, ", ") + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "DAV") + assertEquals(1, headersArray.size) + assertEquals(singleHeaderValue, headersArray[0]) + } + } + + @Test + fun `listHeader with a case insensitive header name`() { + // HTTP header names are case-insensitive. Verify that `response.headers.getAll(name)` correctly retrieves the header regardless of the casing used for `name` (e.g., 'Content-Type' vs 'content-type'). + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues(singleHeaderValue) + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "dav") + assertEquals(singleHeaderValue, headersArray[0]) + assertEquals(1, headersArray.size) + } + } + + @Test + fun `listHeader with an empty string as header name`() { + // Test what happens if an empty string is passed as the header name. This depends on how `response.headers.getAll` handles empty keys. + runBlocking { + val httpClient = getMockEngineWithDAVHeaderValues(singleHeaderValue) + val headersArray = HttpUtils.listHeader(httpClient.get(sampleUrl), "") + assertEquals(0, headersArray.size) + } + } + + + @Test + fun `Full status line with HTTP 1 1 200 code and description`() = + assertEquals(HttpStatusCode.OK, HttpUtils.parseStatusLine("HTTP/1.1 200 OK")) + + @Test + fun `Full status line with HTTP 1 0 404 code and description`() = + assertEquals(HttpStatusCode.NotFound, HttpUtils.parseStatusLine("HTTP/1.0 404 Not Found")) + + @Test + fun `Full status line with HTTP 2 503 code and multi word description`() = + assertEquals(HttpStatusCode.ServiceUnavailable, HttpUtils.parseStatusLine("HTTP/2 503 Service Unavailable")) + + @Test + fun `Full status line with HTTP 1 1 200 code and no description`() = + assertEquals(HttpStatusCode.OK, HttpUtils.parseStatusLine("HTTP/1.1 200")) + + @Test + fun `Partial status line with code and description`() = + assertEquals(HttpStatusCode.OK, HttpUtils.parseStatusLine("HTTP/1.1 200 OK")) + + @Test + fun `Partial status line with code and multi word description`() = + assertEquals(HttpStatusCode.NotFound, HttpUtils.parseStatusLine("404 Not Found")) + + @Test + fun `Partial status line with only code 200`() = + assertEquals(HttpStatusCode.OK, HttpUtils.parseStatusLine("200")) + + + @Test + fun `Partial status line with only code 404`() = + assertEquals(HttpStatusCode.NotFound, HttpUtils.parseStatusLine("404")) + + + @Test + fun `Partial status line with only a known code not having a default description`() = + assertEquals(HttpStatusCode(303, ""), HttpUtils.parseStatusLine("303")) + + @Test + fun `Partial status line with only an unknown code`() = + assertEquals(HttpStatusCode(999, ""), HttpUtils.parseStatusLine("999")) + + @Test + fun `Invalid status line empty string`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("")) + + + @Test + fun `Invalid status line just HTTP version`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("HTTP/1.1")) + + @Test + fun `Invalid status line HTTP version and non numeric code`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("HTTP/1.1 ABC OK")) + + @Test + fun `Invalid status line partial with non numeric code`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("ABC OK")) + + @Test + fun `Invalid status line just non numeric text`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("Invalid")) + + + @Test + fun `Invalid status line HTTP version malformed`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("HTTP1.1 200 OK")) + // Test a status line with a malformed HTTP version: 'HTTP1.1 200 OK'. Expects INVALID_STATUS (as it will be treated as parts.size == 3 but parts[0] doesn't start with 'HTTP/'). + + @Test + fun `Invalid status line status code with leading trailing spaces`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("HTTP/1.1 200 OK")) + + @Test + fun `Invalid status line partial code with leading trailing spaces`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine(" 200 ")) + + @Test + fun `Status line with extra spaces between code and description`() = + assertEquals(HttpStatusCode.OK, HttpUtils.parseStatusLine("HTTP/1.1 200 OK")) + + + @Test + fun `Partial status line with extra spaces between code and description`() = + assertEquals(HttpStatusCode.OK, HttpUtils.parseStatusLine("200 OK")) + + @Test + fun `Full status line with negative status code`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("HTTP/1.1 -100 Error")) + + + @Test + fun `Partial status line with negative status code`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine("-100")) + + @Test + fun `Status line with only spaces`() = + assertEquals(INVALID_STATUS, HttpUtils.parseStatusLine(" ")) + + + @Test + fun `Status line with special characters in description`() = + assertEquals(HttpStatusCode(200, "Description with !@#\$%^&*()"), HttpUtils.parseStatusLine("HTTP/1.1 200 Description with !@#\$%^&*()")) + + @Test + fun `Status line with numeric description only partial case `() = + assertEquals(HttpStatusCode(200, "404"), HttpUtils.parseStatusLine("HTTP/1.1 200 404")) } \ No newline at end of file diff --git a/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt index c468ccc..15dba6a 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt @@ -10,7 +10,7 @@ package at.bitfire.dav4jvm -import okhttp3.HttpUrl.Companion.toHttpUrl +import io.ktor.http.Url import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -37,31 +37,31 @@ class UrlUtilsTest { @Test fun testOmitTrailingSlash() { - assertEquals("http://host/resource".toHttpUrl(), UrlUtils.omitTrailingSlash("http://host/resource".toHttpUrl())) - assertEquals("http://host/resource".toHttpUrl(), UrlUtils.omitTrailingSlash("http://host/resource/".toHttpUrl())) + assertEquals(Url("http://host/resource"), UrlUtils.omitTrailingSlash(Url("http://host/resource"))) + assertEquals(Url("http://host/resource"), UrlUtils.omitTrailingSlash(Url("http://host/resource/"))) } @Test fun testWithTrailingSlash() { - assertEquals("http://host/resource/".toHttpUrl(), UrlUtils.withTrailingSlash("http://host/resource".toHttpUrl())) - assertEquals("http://host/resource/".toHttpUrl(), UrlUtils.withTrailingSlash("http://host/resource/".toHttpUrl())) + assertEquals(Url("http://host/resource/"), UrlUtils.withTrailingSlash(Url("http://host/resource"))) + assertEquals(Url("http://host/resource/"), UrlUtils.withTrailingSlash(Url("http://host/resource/"))) } @Test fun testHttpUrl_EqualsForWebDAV() { - assertTrue("http://host/resource".toHttpUrl().equalsForWebDAV("http://host/resource".toHttpUrl())) - assertTrue("http://host:80/resource".toHttpUrl().equalsForWebDAV("http://host/resource".toHttpUrl())) - assertTrue("https://HOST:443/resource".toHttpUrl().equalsForWebDAV("https://host/resource".toHttpUrl())) - assertTrue("https://host:443/my@dav/".toHttpUrl().equalsForWebDAV("https://host/my%40dav/".toHttpUrl())) - assertTrue("http://host/resource".toHttpUrl().equalsForWebDAV("http://host/resource#frag1".toHttpUrl())) + assertTrue(Url("http://host/resource").equalsForWebDAV(Url("http://host/resource"))) + assertTrue(Url("http://host:80/resource").equalsForWebDAV(Url("http://host/resource"))) + assertTrue(Url("https://HOST:443/resource").equalsForWebDAV(Url("https://host/resource"))) + assertTrue(Url("https://host:443/my@dav/").equalsForWebDAV(Url("https://host/my%40dav/"))) + assertTrue(Url("http://host/resource").equalsForWebDAV(Url("http://host/resource#frag1"))) - assertFalse("http://host/resource".toHttpUrl().equalsForWebDAV("http://host/resource/".toHttpUrl())) - assertFalse("http://host/resource".toHttpUrl().equalsForWebDAV("http://host:81/resource".toHttpUrl())) + assertFalse(Url("http://host/resource").equalsForWebDAV(Url("http://host/resource/"))) + assertFalse(Url("http://host/resource").equalsForWebDAV(Url("http://host:81/resource"))) - assertTrue("https://www.example.com/folder/[X]Y!.txt".toHttpUrl().equalsForWebDAV("https://www.example.com/folder/[X]Y!.txt".toHttpUrl())) - assertTrue("https://www.example.com/folder/%5BX%5DY!.txt".toHttpUrl().equalsForWebDAV("https://www.example.com/folder/[X]Y!.txt".toHttpUrl())) - assertTrue("https://www.example.com/folder/%5bX%5dY%21.txt".toHttpUrl().equalsForWebDAV("https://www.example.com/folder/[X]Y!.txt".toHttpUrl())) + assertTrue(Url("https://www.example.com/folder/[X]Y!.txt").equalsForWebDAV(Url("https://www.example.com/folder/[X]Y!.txt"))) + assertTrue(Url("https://www.example.com/folder/%5BX%5DY!.txt").equalsForWebDAV(Url("https://www.example.com/folder/[X]Y!.txt"))) + assertTrue(Url("https://www.example.com/folder/%5bX%5dY%21.txt").equalsForWebDAV(Url("https://www.example.com/folder/[X]Y!.txt"))) } } \ No newline at end of file diff --git a/src/test/kotlin/at/bitfire/dav4jvm/XmlReaderTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/XmlReaderTest.kt index a3f3b10..50792d6 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/XmlReaderTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/XmlReaderTest.kt @@ -10,8 +10,7 @@ package at.bitfire.dav4jvm -import okhttp3.MediaType -import okhttp3.MediaType.Companion.toMediaType +import io.ktor.http.ContentType import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -155,11 +154,11 @@ class XmlReaderTest { parser.next() val reader = XmlReader(parser) - val types = mutableListOf() + val types = mutableListOf() reader.readContentTypes(Property.Name("", "test"), types::add) assertEquals(2, types.size) - assertEquals("text/plain".toMediaType(), types[0]) - assertEquals("application/json".toMediaType(), types[1]) + assertEquals(ContentType.Text.Plain, types[0]) + assertEquals(ContentType.Application.Json, types[1]) } } \ No newline at end of file diff --git a/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt index d7c2f9c..5d92c43 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt @@ -14,17 +14,23 @@ import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.Property import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV import at.bitfire.dav4jvm.property.webdav.ResourceType -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import org.junit.After -import org.junit.Assert.* -import org.junit.Before +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.prepareRequest +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.http.headersOf +import io.ktor.http.withCharset +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -33,17 +39,7 @@ import java.io.ObjectOutputStream class DavExceptionTest { - private val httpClient = OkHttpClient.Builder() - .followRedirects(false) - .build() - private val mockServer = MockWebServer() - private fun sampleUrl() = mockServer.url("/dav/") - - @Before - fun startServer() = mockServer.start() - - @After - fun stopServer() = mockServer.close() + val sampleUrl = Url("https://127.0.0.1/dav/") /** @@ -51,120 +47,146 @@ class DavExceptionTest { */ @Test fun testRequestLargeTextError() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode.NoContent, // 204 No content + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + ) + } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } val builder = StringBuilder() builder.append(CharArray(DavException.MAX_EXCERPT_SIZE+100) { '*' }) val body = builder.toString() - val e = DavException("Error with large request body", null, Response.Builder() - .request(Request.Builder() - .url("http://example.com") - .post(body.toRequestBody("text/plain".toMediaType())) - .build()) - .protocol(Protocol.HTTP_1_1) - .code(204) - .message("No Content") - .build()) - - assertTrue(e.errors.isEmpty()) - assertEquals( - body.substring(0, DavException.MAX_EXCERPT_SIZE), - e.requestBody - ) + runBlocking { + httpClient.prepareRequest(sampleUrl) { + method = HttpMethod.Post + headers.append(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + setBody(body) + }.execute { response -> + val e = DavException("Error with large request body", null, response) + assertTrue(e.errors.isEmpty()) + assertEquals( + body.substring(0, DavException.MAX_EXCERPT_SIZE), + e.requestBody + ) + } + } } + /** * Test a large HTML response which has a multi-octet UTF-8 character * exactly at the cut-off position. */ @Test fun testResponseLargeTextError() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) val builder = StringBuilder() builder.append(CharArray(DavException.MAX_EXCERPT_SIZE-1) { '*' }) builder.append("\u03C0") // Pi val body = builder.toString() - mockServer.enqueue( - MockResponse.Builder() - .code(404) - .setHeader("Content-Type", "text/html") - .body(body) - .build() - ) - try { - dav.propfind(0, ResourceType.NAME) { _, _ -> } - fail("Expected HttpException") - } catch (e: HttpException) { - assertEquals(e.code, 404) - assertTrue(e.errors.isEmpty()) - assertEquals( - body.substring(0, DavException.MAX_EXCERPT_SIZE-1), - e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE-1) + val mockEngine = MockEngine { request -> + respond( + content = body, + status = HttpStatusCode.NotFound, // 404 + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Html.toString()) ) } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } + + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> } + fail("Expected HttpException") + } catch (e: HttpException) { + assertEquals(HttpStatusCode.NotFound.value, e.code) + assertTrue(e.errors.isEmpty()) + assertEquals( + body.substring(0, DavException.MAX_EXCERPT_SIZE - 1), + e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE - 1) + ) + } + } } + @Test fun testResponseNonTextError() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - - mockServer.enqueue( - MockResponse.Builder() - .code(403) - .setHeader("Content-Type", "application/octet-stream") - .body("12345") - .build() - ) - try { - dav.propfind(0, ResourceType.NAME) { _, _ -> } - fail("Expected HttpException") - } catch (e: HttpException) { - assertEquals(e.code, 403) - assertTrue(e.errors.isEmpty()) - assertNull(e.responseBody) + + val mockEngine = MockEngine { request -> + respond( + content = "12345", + status = HttpStatusCode.Forbidden, // 404 + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.OctetStream.toString()) + ) + } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } + + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> } + fail("Expected HttpException") + } catch (e: HttpException) { + assertEquals(HttpStatusCode.Forbidden.value, e.code) + assertTrue(e.errors.isEmpty()) + assertNull(e.responseBody) + } } } + @Test fun testSerialization() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - - mockServer.enqueue( - MockResponse.Builder() - .code(500) - .setHeader("Content-Type", "text/plain") - .body("12345") - .build() - ) - try { - dav.propfind(0, ResourceType.NAME) { _, _ -> } - fail("Expected DavException") - } catch (e: DavException) { - val baos = ByteArrayOutputStream() - val oos = ObjectOutputStream(baos) - oos.writeObject(e) - - val ois = ObjectInputStream(ByteArrayInputStream(baos.toByteArray())) - val e2 = ois.readObject() as HttpException - assertEquals(500, e2.code) - assertTrue(e2.responseBody!!.contains("12345")) + + val mockEngine = MockEngine { request -> + respond( + content = "12345", + status = HttpStatusCode.InternalServerError, // 500 + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + ) + } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> } + fail("Expected DavException") + } catch (e: DavException) { + val baos = ByteArrayOutputStream() + val oos = ObjectOutputStream(baos) + oos.writeObject(e) + + val ois = ObjectInputStream(ByteArrayInputStream(baos.toByteArray())) + val e2 = ois.readObject() as HttpException + assertEquals(HttpStatusCode.InternalServerError.value, e2.code) + assertTrue(e2.responseBody!!.contains("12345")) + } } } + /** * Test precondition XML element (sample from RFC 4918 16) */ @Test fun testXmlError() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) val body = "\n" + "\n" + @@ -172,21 +194,28 @@ class DavExceptionTest { " /workspace/webdav/\n" + " \n" + "\n" - mockServer.enqueue( - MockResponse.Builder() - .code(423) - .setHeader("Content-Type", "application/xml; charset=\"utf-8\"") - .body(body) - .build() - ) - try { - dav.propfind(0, ResourceType.NAME) { _, _ -> } - fail("Expected HttpException") - } catch (e: HttpException) { - assertEquals(e.code, 423) - assertTrue(e.errors.any { it.name == Property.Name(NS_WEBDAV, "lock-token-submitted") }) - assertEquals(body, e.responseBody) + + val mockEngine = MockEngine { request -> + respond( + content = body, + status = HttpStatusCode.Locked, // 423 + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.withCharset(Charsets.UTF_8) .toString()) + ) + } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } + val dav = DavResource(httpClient, sampleUrl) + + runBlocking { + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> } + fail("Expected HttpException") + } catch (e: HttpException) { + assertEquals(HttpStatusCode.Locked.value, e.code) + assertTrue(e.errors.any { it.name == Property.Name(NS_WEBDAV, "lock-token-submitted") }) + assertEquals(body, e.responseBody) + } } } - } \ No newline at end of file diff --git a/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt index b2298a7..033a295 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt @@ -10,12 +10,15 @@ package at.bitfire.dav4jvm.exception -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.prepareRequest +import io.ktor.client.request.setBody +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertTrue import org.junit.Test @@ -25,23 +28,26 @@ class HttpExceptionTest { @Test fun testHttpFormatting() { - val request = Request.Builder() - .post("REQUEST\nBODY".toRequestBody("text/something".toMediaType())) - .url("http://example.com") - .build() - val response = Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(500) - .message(responseMessage) - .body("SERVER\r\nRESPONSE".toResponseBody("text/something-other".toMediaType())) - .build() - val e = HttpException(response) - assertTrue(e.message!!.contains("500")) - assertTrue(e.message!!.contains(responseMessage)) - assertTrue(e.requestBody!!.contains("REQUEST\nBODY")) - assertTrue(e.responseBody!!.contains("SERVER\r\nRESPONSE")) - } + val mockEngine = MockEngine { request -> + respond( + content = "SERVER\r\nRESPONSE", + status = HttpStatusCode(500, responseMessage), + headers = headersOf(HttpHeaders.ContentType, "text/something") + ) + } + val httpClient = HttpClient(mockEngine) + runBlocking { + httpClient.prepareRequest("http://example.com") { + setBody("REQUEST\nBODY") + }.execute { response -> + val e = HttpException(response) + assertTrue(e.message!!.contains("500")) + assertTrue(e.message!!.contains(responseMessage)) + assertTrue(e.responseBody!!.contains("SERVER\r\nRESPONSE")) + assertTrue(e.requestBody!!.contains("REQUEST\nBODY")) + } + } + } } diff --git a/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt index c214d32..c01309b 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt @@ -11,9 +11,16 @@ package at.bitfire.dav4jvm.exception import at.bitfire.dav4jvm.HttpUtils -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.request.get +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.http.headersOf +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -22,40 +29,59 @@ import java.time.Instant class ServiceUnavailableExceptionTest { - val response503 = Response.Builder() - .request(Request.Builder() - .url("http://www.example.com") - .get() - .build()) - .protocol(Protocol.HTTP_1_1) - .code(503).message("Try later") - .build() + val mockUrl = Url("http://www.example.com") @Test fun testRetryAfter_NoTime() { - val e = ServiceUnavailableException(response503) - assertNull(e.retryAfter) + + val mockEngine = MockEngine { request -> + respondError(HttpStatusCode.ServiceUnavailable) // 503 + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val e = ServiceUnavailableException(httpClient.get(mockUrl)) + assertNull(e.retryAfter) + } } @Test fun testRetryAfter_Seconds() { - val response = response503.newBuilder() - .header("Retry-After", "120") - .build() - val e = ServiceUnavailableException(response) - assertNotNull(e.retryAfter) - assertTrue(withinTimeRange(e.retryAfter!!, 120)) + val mockEngine = MockEngine { request -> + respond( + content = "", + status = HttpStatusCode.ServiceUnavailable, // 503 + headers = headersOf(HttpHeaders.RetryAfter, "120") + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val response = httpClient.get(mockUrl) + val e = ServiceUnavailableException(response) + assertNotNull(e.retryAfter) + assertTrue(withinTimeRange(e.retryAfter!!, 120)) + } } @Test fun testRetryAfter_Date() { + val after30min = Instant.now().plusSeconds(30*60) - val response = response503.newBuilder() - .header("Retry-After", HttpUtils.formatDate(after30min)) - .build() - val e = ServiceUnavailableException(response) - assertNotNull(e.retryAfter) - assertTrue(withinTimeRange(e.retryAfter!!, 30*60)) + val mockEngine = MockEngine { request -> + respondError( + status = HttpStatusCode.ServiceUnavailable, // 503 + headers = headersOf(HttpHeaders.RetryAfter, HttpUtils.formatDate(after30min)) + ) + } + val httpClient = HttpClient(mockEngine) + + runBlocking { + val response = httpClient.get(mockUrl) + val e = ServiceUnavailableException(response) + assertNotNull(e.retryAfter) + assertTrue(withinTimeRange(e.retryAfter!!, 30*60)) + } }