diff --git a/build.gradle.kts b/build.gradle.kts index edb293b..11a4342 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,6 +55,7 @@ tasks.withType().configureEach { dependencies { api(libs.okhttp) + api(libs.spotbugs.annotations) api(libs.xpp3) testImplementation(libs.junit4) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da3ef7c..0bd677d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,12 +3,14 @@ dokka = "2.0.0" junit4 = "4.13.2" kotlin = "2.2.0" okhttpVersion = "5.1.0" +spotbugs = "4.9.4" xpp3Version = "1.1.6" [libraries] junit4 = { module = "junit:junit", version.ref = "junit4" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttpVersion" } +spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", version.ref = "spotbugs" } xpp3 = { module = "org.ogce:xpp3", version.ref = "xpp3Version" } [plugins] diff --git a/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt b/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt index 25a94bb..9eb5ae7 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt @@ -16,6 +16,7 @@ import at.bitfire.dav4jvm.XmlUtils.propertyName import at.bitfire.dav4jvm.exception.ConflictException import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.ForbiddenException +import at.bitfire.dav4jvm.exception.GoneException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.exception.NotFoundException import at.bitfire.dav4jvm.exception.PreconditionFailedException @@ -39,7 +40,6 @@ 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 @@ -666,34 +666,20 @@ open class DavResource @JvmOverloads constructor( * * @throws HttpException in case of an HTTP error */ - protected fun checkStatus(response: Response) = - checkStatus(response.code, response.message, response) - - /** - * 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) + protected fun checkStatus(response: Response) { + if (response.code / 100 == 2) // everything OK return - throw when (code) { - HttpURLConnection.HTTP_UNAUTHORIZED -> - if (response != null) UnauthorizedException(response) else UnauthorizedException(message) - HttpURLConnection.HTTP_FORBIDDEN -> - if (response != null) ForbiddenException(response) else ForbiddenException(message) - HttpURLConnection.HTTP_NOT_FOUND -> - if (response != null) NotFoundException(response) else NotFoundException(message) - HttpURLConnection.HTTP_CONFLICT -> - if (response != null) ConflictException(response) else ConflictException(message) - HttpURLConnection.HTTP_PRECON_FAILED -> - if (response != null) PreconditionFailedException(response) else PreconditionFailedException(message) - HttpURLConnection.HTTP_UNAVAILABLE -> - if (response != null) ServiceUnavailableException(response) else ServiceUnavailableException(message) - else -> - if (response != null) HttpException(response) else HttpException(code, message) + throw when (response.code) { + 401 -> UnauthorizedException(response) + 403 -> ForbiddenException(response) + 404 -> NotFoundException(response) + 409 -> ConflictException(response) + 410 -> GoneException(response) + 412 -> PreconditionFailedException(response) + 503 -> ServiceUnavailableException(response) + else -> HttpException(response) } } @@ -739,7 +725,7 @@ open class DavResource @JvmOverloads constructor( */ fun assertMultiStatus(response: Response) { if (response.code != HTTP_MULTISTATUS) - throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", httpResponse = response) + throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", response = response) response.peekBody(XML_SIGNATURE.size.toLong()).use { body -> body.contentType()?.let { mimeType -> @@ -760,7 +746,7 @@ open class DavResource @JvmOverloads constructor( logger.log(Level.WARNING, "Couldn't scan for XML signature", e) } - throw DavException("Received non-XML 207 Multi-Status", httpResponse = response) + throw DavException("Received non-XML 207 Multi-Status", response = response) } } ?: logger.warning("Received 207 Multi-Status without Content-Type, assuming XML") } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/Error.kt b/src/main/kotlin/at/bitfire/dav4jvm/Error.kt index 6a818e8..bb9cf68 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/Error.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/Error.kt @@ -19,9 +19,11 @@ import java.io.Serializable * name. Subclassed errors may have more specific information available. * * At the moment, there is no logic for subclassing errors. + * + * @param name property name for the XML error element */ -class Error( - val name: Property.Name +data class Error( + val name: Property.Name ): Serializable { companion object { diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt index 03e52c4..6a9fc2a 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt @@ -11,11 +11,12 @@ package at.bitfire.dav4jvm.exception import okhttp3.Response -import java.net.HttpURLConnection class ConflictException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_CONFLICT, message) + constructor(response: Response) : super(response) { + if (response.code != 409) + throw IllegalArgumentException("Status code must be 409") + } -} +} \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt index 19e8042..a215587 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt @@ -13,140 +13,148 @@ package at.bitfire.dav4jvm.exception 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 org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException import java.io.ByteArrayOutputStream -import java.io.IOException -import java.io.Serializable -import java.lang.Long.min -import java.util.logging.Level -import java.util.logging.Logger +import java.io.StringReader +import javax.annotation.WillNotClose +import kotlin.math.min /** * Signals that an error occurred during a WebDAV-related operation. * - * This could be a logical error like when a required ETag has not been - * received, but also an explicit HTTP error. + * This could be a logical error like when a required ETag has not been received, but also an explicit HTTP error + * (usually with a subclass of [HttpException], which in turn extends this class). + * + * Often, HTTP response bodies contain valuable information about the error in text format (for instance, a HTML page + * that contains details about the error) and/or as `` XML elements. However, such response bodies + * are sometimes very large. + * + * So, if possible and useful, a size-limited excerpt of the associated HTTP request and response can be + * attached and subsequently included in application-level debug info or shown to the user. + * + * Note: [Exception] is serializable, so objects of this class must contain only serializable objects. + * + * @param statusCode status code of associated HTTP response + * @param requestExcerpt cached excerpt of associated HTTP request body + * @param responseExcerpt cached excerpt of associated HTTP response body + * @param errors precondition/postcondition XML elements which have been found in the XML response */ open class DavException @JvmOverloads constructor( - message: String, - ex: Throwable? = null, - - /** - * An associated HTTP [Response]. Will be closed after evaluation. - */ - httpResponse: Response? = 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")) - - } - - private val logger - get() = Logger.getLogger(javaClass.name) - - var request: String? = null + message: String? = null, + cause: Throwable? = null, + statusCode: Int? = null, + requestExcerpt: String? = null, + responseExcerpt: String? = null, + errors: List = emptyList() +): Exception(message, cause) { + + var statusCode: Int? = statusCode + private set - /** - * Body excerpt of [request] (up to [MAX_EXCERPT_SIZE] characters). Only available - * if the HTTP request body was textual content and could be read again. - */ - var requestBody: String? = null + var requestExcerpt: String? = requestExcerpt private set - val response: String? + var responseExcerpt: String? = responseExcerpt + private set - /** - * Body excerpt of [response] (up to [MAX_EXCERPT_SIZE] characters). Only available - * if the HTTP response body was textual content. - */ - var responseBody: String? = null + var errors: List = errors private set /** - * Precondition/postcondition XML elements which have been found in the XML response. + * Takes the request, response and errors from a given HTTP response. + * + * @param response response to extract status code and request/response excerpt from (if possible) + * @param message optional exception message + * @param cause optional exception cause */ - var errors: List = listOf() - private set + constructor( + message: String?, + @WillNotClose response: Response, + cause: Throwable? = null + ) : this(message, cause) { + // extract status code + statusCode = response.code + + // extract request body if it's text + val request = response.request + val requestExcerptBuilder = StringBuilder( + "${request.method} ${request.url}" + ) + request.body?.let { requestBody -> + if (requestBody.contentType()?.isText() == true) { + // Unfortunately Buffer doesn't have a size limit. + // However large bodies are usually streaming/one-shot away. + val buffer = Buffer() + requestBody.writeTo(buffer) + + ByteArrayOutputStream().use { baos -> + buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong())) + requestExcerptBuilder + .append("\n\n") + .append(baos.toString()) + } + } else + requestExcerptBuilder.append("\n\n") + } + requestExcerpt = requestExcerptBuilder.toString() + + // extract response body if it's text + val mimeType = response.body.contentType() + val responseBody = + if (mimeType?.isText() == true) + try { + response.peekBody(MAX_EXCERPT_SIZE.toLong()).string() + } catch (_: Exception) { + // response body not available anymore, probably already consumed / closed + null + } + else + null + responseExcerpt = responseBody + + // get XML errors from request body excerpt + if (mimeType?.isXml() == true && responseBody != null) + errors = extractErrors(responseBody) + } + + private fun extractErrors(xml: String): List { + try { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader(xml)) + + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.depth == 1) + if (parser.propertyName() == Error.NAME) + return Error.parseError(parser) + eventType = parser.next() + } + } catch (_: XmlPullParserException) { + // Couldn't parse XML, either invalid or maybe it wasn't even XML + } + return emptyList() + } - init { - if (httpResponse != null) { - response = httpResponse.toString() - try { - request = httpResponse.request.toString() + companion object { - httpResponse.request.body?.let { body -> - body.contentType()?.let { type -> - if (isPlainText(type)) { - val buffer = Buffer() - body.writeTo(buffer) + /** + * maximum size of extracted response body + */ + const val MAX_EXCERPT_SIZE = 20*1024 - val baos = ByteArrayOutputStream() - buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong())) + private fun MediaType.isText() = + type == "text" || + (type == "application" && subtype in arrayOf("html", "xml")) - requestBody = baos.toString(type.charset(Charsets.UTF_8)!!.name()) - } - } - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't read HTTP request", e) - requestBody = "Couldn't read HTTP request: ${e.message}" - } + private fun MediaType.isXml() = + type in arrayOf("application", "text") && subtype == "xml" - 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() - } - } - - 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() - } - } catch (e: XmlPullParserException) { - logger.log(Level.WARNING, "Couldn't parse XML response", 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() - } - } 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..1116507 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt @@ -15,7 +15,9 @@ import java.net.HttpURLConnection class ForbiddenException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_FORBIDDEN, message) + constructor(response: Response) : super(response) { + if (response.code != 403) + throw IllegalArgumentException("Status code must be 403") + } -} +} \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt index 0cbd9b3..d8f9e0c 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt @@ -11,11 +11,12 @@ package at.bitfire.dav4jvm.exception import okhttp3.Response -import java.net.HttpURLConnection class GoneException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_GONE, message) + constructor(response: Response) : super(response) { + if (response.code != 410) + throw IllegalArgumentException("Status code must be 410") + } -} +} \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt index 08534a4..ccba9bf 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt @@ -13,21 +13,13 @@ package at.bitfire.dav4jvm.exception import okhttp3.Response /** - * Signals that a HTTP error was sent by the server. + * Signals that a HTTP error was sent by the server in the context of a WebDAV operation. */ open class HttpException: DavException { - var code: Int + constructor(response: Response) : super( + message = "HTTP ${response.code} ${response.message}", + response = response + ) - constructor(response: Response): super( - "HTTP ${response.code} ${response.message}", - httpResponse = response - ) { - code = response.code - } - - constructor(code: Int, message: String?): super("HTTP $code $message") { - this.code = code - } - -} +} \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/InvalidPropertyException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/InvalidPropertyException.kt index bdda00e..ca5e118 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/InvalidPropertyException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/InvalidPropertyException.kt @@ -15,4 +15,4 @@ package at.bitfire.dav4jvm.exception * when parsing something like `...` * because a text value would be expected. */ -class InvalidPropertyException(message: String): Exception(message) +class InvalidPropertyException(message: String): DavException(message) \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt index d04c0ac..0561e5d 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt @@ -11,11 +11,12 @@ package at.bitfire.dav4jvm.exception import okhttp3.Response -import java.net.HttpURLConnection -class NotFoundException: HttpException { +class NotFoundException : HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_NOT_FOUND, message) + constructor(response: Response) : super(response) { + if (response.code != 404) + throw IllegalArgumentException("Status code must be 404") + } } diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt index 6760736..ae05ec6 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt @@ -15,7 +15,9 @@ import java.net.HttpURLConnection class PreconditionFailedException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_PRECON_FAILED, message) + constructor(response: Response) : super(response) { + if (response.code != 412) + throw IllegalArgumentException("Status code must be 412") + } -} +} \ No newline at end of file diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt index 0665fc0..622141f 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt @@ -15,23 +15,21 @@ import at.bitfire.dav4jvm.exception.ServiceUnavailableException.Companion.DELAY_ 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 java.time.Instant import java.util.logging.Level import java.util.logging.Logger -class ServiceUnavailableException : HttpException { +class ServiceUnavailableException(response: Response) : HttpException(response) { private val logger get() = Logger.getLogger(javaClass.name) val retryAfter: Instant? - constructor(message: String?) : super(HttpURLConnection.HTTP_UNAVAILABLE, message) { - retryAfter = null - } + init { + if (response.code != 503) + throw IllegalArgumentException("Status code must be 503") - constructor(response: Response) : super(response) { // Retry-After = "Retry-After" ":" ( HTTP-date | delta-seconds ) // HTTP-date = rfc1123-date | rfc850-date | asctime-date diff --git a/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt b/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt index 98a6183..dbeca1e 100644 --- a/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt +++ b/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt @@ -11,11 +11,12 @@ package at.bitfire.dav4jvm.exception import okhttp3.Response -import java.net.HttpURLConnection class UnauthorizedException: HttpException { - constructor(response: Response): super(response) - constructor(message: String?): super(HttpURLConnection.HTTP_UNAUTHORIZED, message) + constructor(response: Response) : super(response) { + if (response.code != 401) + throw IllegalArgumentException("Status code must be 401") + } -} +} \ 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..7eb8d32 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt @@ -249,7 +249,7 @@ class DavCollectionTest { collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME) { _, _ -> } fail("Expected HttpException") } catch (e: HttpException) { - assertEquals(507, e.code) + assertEquals(507, e.statusCode) assertTrue(e.errors.any { it.name == Property.Name(NS_WEBDAV, "number-of-matches-within-limits") }) assertEquals(1, e.errors.size) } diff --git a/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt index d7c2f9c..c90c1d1 100644 --- a/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt +++ b/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt @@ -10,20 +10,18 @@ package at.bitfire.dav4jvm.exception -import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Error 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.Headers 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.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import java.io.ByteArrayInputStream @@ -33,11 +31,10 @@ import java.io.ObjectOutputStream class DavExceptionTest { - private val httpClient = OkHttpClient.Builder() + private val client = OkHttpClient.Builder() .followRedirects(false) .build() private val mockServer = MockWebServer() - private fun sampleUrl() = mockServer.url("/dav/") @Before fun startServer() = mockServer.start() @@ -46,146 +43,192 @@ class DavExceptionTest { fun stopServer() = mockServer.close() - /** - * Test truncation of a too large plain text request in [DavException]. - */ @Test - fun testRequestLargeTextError() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - - 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 - ) + fun `Construct from closed response`() { + mockServer.enqueue(MockResponse( + code = 404, + body = "Page not found" + )) + val response = client.newCall(Request.Builder() + .get() + .url(mockServer.url("/")) + .build()).execute() + response.close() + + val result = DavException("Test", response) + assertNull(result.responseExcerpt) } - /** - * 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()) + fun `requestExcerpt (binary blob)`() { + mockServer.enqueue(MockResponse( + code = 404, + body = "Page not found" + )) + val url = mockServer.url("/") + client.newCall(Request.Builder() + .url(url) + .post("Sample".toRequestBody("application/test".toMediaType())) + .build() + ).execute().use { response -> + val result = DavException("Test", response) + assertEquals("POST $url\n\n", result.requestExcerpt) + } + } + + @Test + fun `requestExcerpt (large CSS text)`() { + mockServer.enqueue(MockResponse( + code = 404, + body = "Page not found" + )) + val url = mockServer.url("/") + client.newCall(Request.Builder() + .url(url) + .post("*".repeat(DavException.MAX_EXCERPT_SIZE * 2).toRequestBody("text/css".toMediaType())) + .build() + ).execute().use { response -> + val result = DavException("Test", response) + val truncatedText = "*".repeat(DavException.MAX_EXCERPT_SIZE) + assertEquals("POST $url\n\n$truncatedText", result.requestExcerpt) + } + } + + @Test + fun `responseExcerpt (binary blob)`() { + mockServer.enqueue(MockResponse( + code = 404, + body = "Evil binary data", + headers = Headers.headersOf("Content-Type", "application/octet-stream") + )) + val url = mockServer.url("/") + client.newCall(Request.Builder() + .url(url) + .get() + .build() + ).execute().use { response -> + val result = DavException("Test", response) + assertNull(result.responseExcerpt) + } + } + + @Test + fun `responseExcerpt (HTML)`() { + mockServer.enqueue(MockResponse( + code = 404, + body = "Interesting details about error", + headers = Headers.headersOf("Content-Type", "text/html") + )) + val url = mockServer.url("/") + client.newCall(Request.Builder() + .url(url) + .get() + .build() + ).execute().use { response -> + val result = DavException("Test", response) + assertEquals("Interesting details about error", result.responseExcerpt) + } + } + + @Test + fun `responseExcerpt (large HTML)`() { + mockServer.enqueue(MockResponse( + code = 404, + body = "0123456789".repeat(3*1024), // 30 kB + headers = Headers.headersOf("Content-Type", "text/html") + )) + val url = mockServer.url("/") + client.newCall(Request.Builder() + .url(url) + .get() + .build() + ).execute().use { response -> + val result = DavException("Test", response) assertEquals( - body.substring(0, DavException.MAX_EXCERPT_SIZE-1), - e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE-1) + "0123456789".repeat(2*1024), // limited to 20 kB + result.responseExcerpt ) } } @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) + fun `responseExcerpt (no Content-Type)`() { + mockServer.enqueue(MockResponse( + code = 404, + body = "Maybe evil binary data" + )) + val url = mockServer.url("/") + client.newCall(Request.Builder() + .url(url) + .get() + .build() + ).execute().use { response -> + val result = DavException("Test", response) + assertNull(result.responseExcerpt) } } @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")) + fun `responseExcerpt (XML with error elements)`() { + val xml = """ + + + + /locked/ + + """.trimIndent() + mockServer.enqueue(MockResponse( + code = 404, + body = xml, + headers = Headers.headersOf("Content-Type", "application/xml") + )) + val url = mockServer.url("/") + client.newCall(Request.Builder() + .url(url) + .get() + .build() + ).execute().use { response -> + val result = DavException("Test", response) + assertEquals(xml, result.responseExcerpt) + assertEquals( + listOf( + Error(Property.Name("DAV:", "lock-token-submitted")) + ), + result.errors + ) } } - /** - * Test precondition XML element (sample from RFC 4918 16) - */ @Test - fun testXmlError() { - val url = sampleUrl() - val dav = DavResource(httpClient, url) - - val body = "\n" + - "\n" + - " \n" + - " /workspace/webdav/\n" + - " \n" + - "\n" - mockServer.enqueue( - MockResponse.Builder() - .code(423) - .setHeader("Content-Type", "application/xml; charset=\"utf-8\"") - .body(body) - .build() + fun `is Java-serializable`() { + val davException = DavException( + message = "Some Error", + statusCode = 500, + requestExcerpt = "Request Body", + responseExcerpt = "Response Body", + errors = listOf( + Error(Property.Name("Serialized", "Name")) + ) ) - 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) + + // serialize (Java-style as in Serializable interface, not Kotlin serialization) + val blob = ByteArrayOutputStream().use { baos -> + ObjectOutputStream(baos).use { oos -> + oos.writeObject(davException) + } + baos.toByteArray() + } + + // deserialize + ByteArrayInputStream(blob).use { bais -> + ObjectInputStream(bais).use { ois -> + val actual = ois.readObject() as DavException + assertEquals(davException.message, actual.message) + assertEquals(davException.statusCode, actual.statusCode) + assertEquals(davException.requestExcerpt, actual.requestExcerpt) + assertEquals(davException.responseExcerpt, actual.responseExcerpt) + assertEquals(davException.errors, actual.errors) + } } } diff --git a/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt b/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt deleted file mode 100644 index b2298a7..0000000 --- a/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt +++ /dev/null @@ -1,47 +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.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 org.junit.Assert.assertTrue -import org.junit.Test - -class HttpExceptionTest { - - private val responseMessage = "Unknown error" - - @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")) - } - -}