fix: fail on malformed board detail responses

This commit is contained in:
2026-03-16 00:27:45 -04:00
parent 9602b7959f
commit e7ad14902d
2 changed files with 61 additions and 12 deletions

View File

@@ -227,7 +227,9 @@ class HttpKanbnApiClient : KanbnApiClient {
apiKey = apiKey, apiKey = apiKey,
) { code, body -> ) { code, body ->
if (code in 200..299) { if (code in 200..299) {
BoardsApiResult.Success(parseBoardDetail(body, boardId)) parseBoardDetail(body, boardId)
?.let { BoardsApiResult.Success(it) }
?: BoardsApiResult.Failure("Malformed board detail response.")
} else { } else {
BoardsApiResult.Failure(serverMessage(body, code)) BoardsApiResult.Failure(serverMessage(body, code))
} }
@@ -482,9 +484,13 @@ class HttpKanbnApiClient : KanbnApiClient {
return workspaces 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) val root = parseJsonObject(body)
?: return BoardDetail(id = fallbackId, title = "Board", lists = emptyList()) ?: return null
val data = root["data"] as? Map<*, *> val data = root["data"] as? Map<*, *>
val board = (data?.get("board") as? Map<*, *>) val board = (data?.get("board") as? Map<*, *>)
?: data ?: data
@@ -592,17 +598,23 @@ class HttpKanbnApiClient : KanbnApiClient {
private fun jsonEscape(value: String): String { private fun jsonEscape(value: String): String {
val builder = StringBuilder() val builder = StringBuilder()
value.forEach { ch -> value.forEach { ch ->
if (ch.code in 0x00..0x1F) {
when (ch) { when (ch) {
'\\' -> builder.append("\\\\")
'"' -> builder.append("\\\"")
'\b' -> builder.append("\\b") '\b' -> builder.append("\\b")
'\u000C' -> builder.append("\\f") '\u000C' -> builder.append("\\f")
'\n' -> builder.append("\\n") '\n' -> builder.append("\\n")
'\r' -> builder.append("\\r") '\r' -> builder.append("\\r")
'\t' -> builder.append("\\t") '\t' -> builder.append("\\t")
else -> builder.append("\\u%04x".format(ch.code))
}
} else {
when (ch) {
'\\' -> builder.append("\\\\")
'"' -> builder.append("\\\"")
else -> builder.append(ch) else -> builder.append(ch)
} }
} }
}
return builder.toString() return builder.toString()
} }

View File

@@ -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 @Test
fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest { fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest {
TestServer().use { server -> 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 @Test
fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest { fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest {
TestServer().use { server -> TestServer().use { server ->