diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index e416074..ebd8bba 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -227,7 +227,9 @@ class HttpKanbnApiClient : KanbnApiClient { apiKey = apiKey, ) { code, body -> if (code in 200..299) { - BoardsApiResult.Success(parseBoardDetail(body, boardId)) + parseBoardDetail(body, boardId) + ?.let { BoardsApiResult.Success(it) } + ?: BoardsApiResult.Failure("Malformed board detail response.") } else { BoardsApiResult.Failure(serverMessage(body, code)) } @@ -482,9 +484,13 @@ class HttpKanbnApiClient : KanbnApiClient { return workspaces } - private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail { + private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail? { + if (body.isBlank()) { + return BoardDetail(id = fallbackId, title = "Board", lists = emptyList()) + } + val root = parseJsonObject(body) - ?: return BoardDetail(id = fallbackId, title = "Board", lists = emptyList()) + ?: return null val data = root["data"] as? Map<*, *> val board = (data?.get("board") as? Map<*, *>) ?: data @@ -592,15 +598,21 @@ class HttpKanbnApiClient : KanbnApiClient { private fun jsonEscape(value: String): String { val builder = StringBuilder() value.forEach { ch -> - when (ch) { - '\\' -> builder.append("\\\\") - '"' -> builder.append("\\\"") - '\b' -> builder.append("\\b") - '\u000C' -> builder.append("\\f") - '\n' -> builder.append("\\n") - '\r' -> builder.append("\\r") - '\t' -> builder.append("\\t") - else -> builder.append(ch) + if (ch.code in 0x00..0x1F) { + when (ch) { + '\b' -> builder.append("\\b") + '\u000C' -> builder.append("\\f") + '\n' -> builder.append("\\n") + '\r' -> builder.append("\\r") + '\t' -> builder.append("\\t") + else -> builder.append("\\u%04x".format(ch.code)) + } + } else { + when (ch) { + '\\' -> builder.append("\\\\") + '"' -> builder.append("\\\"") + else -> builder.append(ch) + } } } return builder.toString() diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt index d5d9b4c..fb80d73 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt @@ -186,6 +186,25 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun getBoardDetailFailsOnMalformedJsonPayload() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/malformed", + status = 200, + responseBody = "{\"data\": {\"id\": \"broken\"", + ) + + val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "malformed") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals( + "Malformed board detail response.", + (result as BoardsApiResult.Failure).message, + ) + } + } + @Test fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest { TestServer().use { server -> @@ -229,6 +248,24 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun renameListEscapesControlCharactersInRequestBody() = runTest { + TestServer().use { server -> + server.register(path = "/api/v1/lists/l-esc", status = 200, responseBody = "{}") + + val raw = "name\u0000line\u001f\n\tend" + val result = HttpKanbnApiClient().renameList(server.baseUrl, "api", "l-esc", raw) + + assertTrue(result is BoardsApiResult.Success<*>) + val request = server.findRequest("PATCH", "/api/v1/lists/l-esc") + assertNotNull(request) + assertEquals( + "{\"name\":\"name\\u0000line\\u001f\\n\\tend\"}", + request?.body, + ) + } + } + @Test fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest { TestServer().use { server ->