From 6058013951d270038610ebc72660912c88d8c399 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 3 Jun 2026 22:30:48 -0500 Subject: [PATCH] Fix continue_with:null decode crash + add auth decode/integration tests Kratos serialises an empty `continue_with` as explicit `null` (not `[]` or an absent key), which crashed the post-register login decode ("Expected start of the array '[', but had 'n' at $.continue_with"). Make continue_with nullable on the three Kratos models and add coerceInputValues as a backstop for other null-vs-default fields. Tests (all run + passing): - KratosDecodeTest: null/absent continue_with on login + registration - AuthFlowDecodeTest: real captured prod bodies (login, /auth/me, verification) decoded with the real models + the real client Json configs - LiveAuthIntegrationTest: live HTTP through the actual AuthApi against prod (register -> login -> /auth/me -> start-verification -> wrong-code), gated by RUN_LIVE_IT=1 so it never runs on a normal build Co-Authored-By: Claude Opus 4.8 (1M context) --- .../network/LiveAuthIntegrationTest.kt | 68 +++++++ .../kotlin/com/tt/honeyDue/models/Kratos.kt | 13 +- .../kotlin/com/tt/honeyDue/network/AuthApi.kt | 9 +- .../tt/honeyDue/network/AuthFlowDecodeTest.kt | 176 ++++++++++++++++++ .../tt/honeyDue/network/KratosDecodeTest.kt | 86 +++++++++ iosApp/iosApp/Localizable.xcstrings | 3 + 6 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/LiveAuthIntegrationTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/AuthFlowDecodeTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/KratosDecodeTest.kt diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/LiveAuthIntegrationTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/LiveAuthIntegrationTest.kt new file mode 100644 index 0000000..9af8302 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/LiveAuthIntegrationTest.kt @@ -0,0 +1,68 @@ +package com.tt.honeyDue.network + +import com.tt.honeyDue.data.DataManager +import com.tt.honeyDue.models.RegisterRequest +import com.tt.honeyDue.models.VerifyEmailRequest +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * LIVE end-to-end integration test: drives the REAL [AuthApi] (the exact client + * code the app runs, OkHttp engine, production URLs from ApiConfig) against the + * live honeyDue API + Ory Kratos. No fixtures, no hand-written JSON — every + * response is decoded by the real models over the wire, so a contract mismatch + * between the API and the KMP client fails here. + * + * Skipped unless RUN_LIVE_IT=1 (it creates a real throwaway account and needs + * network), so it never runs on a normal build/CI. Run explicitly: + * RUN_LIVE_IT=1 ./gradlew :composeApp:testDebugUnitTest \ + * --tests "com.tt.honeyDue.network.LiveAuthIntegrationTest" + */ +class LiveAuthIntegrationTest { + + private fun liveEnabled() = System.getenv("RUN_LIVE_IT") == "1" + + @Test + fun passwordSignupFlowAgainstProd() = runBlocking { + assumeTrue("set RUN_LIVE_IT=1 to run the live integration test", liveEnabled()) + + val api = AuthApi() // ApiClient.httpClient (OkHttp) + PROD URLs + val email = "kmpqa-" + System.currentTimeMillis() + "@example.com" + val password = "KmpQa1!Test99" + + // 1. Register: admin-create via /api/auth/register/ then immediate login. + // Exercises the createAccount HTTP call AND the login decode that + // crashed on "continue_with": null. + val reg = api.register( + RegisterRequest(username = "kmpqa", email = email, password = password, firstName = "Kmp", lastName = "Qa"), + ) + assertTrue(reg is ApiResult.Success, "register should succeed, got: $reg") + val auth = (reg as ApiResult.Success).data + assertTrue(auth.token.isNotBlank(), "session token must be present") + assertEquals(email, auth.user.email) + assertFalse(auth.user.verified, "a fresh password account must start unverified") + + // 2. /auth/me decodes through the ApiClient json config (User model). + val me = api.getCurrentUser(auth.token) + assertTrue(me is ApiResult.Success, "auth/me should succeed, got: $me") + assertEquals(email, (me as ApiResult.Success).data.email) + + // 3. Start the client-owned verification flow — decodes the flow body and + // stores the flow id; sends the single code. + val started = api.startEmailVerification(email) + assertTrue(started is ApiResult.Success, "startEmailVerification should succeed, got: $started") + assertNotNull(DataManager.pendingVerificationFlowId.value, "a verification flow id must be stored") + + // 4. Submit a deliberately wrong code: must decode the re-rendered flow + // and return a clean Error (not a parse crash). + val wrong = api.verifyEmail(auth.token, VerifyEmailRequest(code = "000000")) + assertTrue(wrong is ApiResult.Error, "wrong code must return Error, got: $wrong") + + DataManager.setPendingVerificationFlowId(null) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt index 3e53115..4707e98 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt @@ -39,8 +39,13 @@ data class KratosFlow( * Post-submission instructions. On a completed recovery flow this carries * the privileged session token (`set_ory_session_token`) and the settings * flow to finish the password change in (`show_settings_ui`). + * + * Nullable because Kratos serialises this as an explicit `"continue_with": + * null` (not an empty array / absent key) when there are no items — a + * non-nullable List would throw on decode ("Expected start of the array + * '[', but had 'n'"). */ - @SerialName("continue_with") val continueWith: List = emptyList(), + @SerialName("continue_with") val continueWith: List? = null, ) /** @@ -149,7 +154,8 @@ data class KratosSession( data class KratosLoginSuccess( val session: KratosSession, @SerialName("session_token") val sessionToken: String, - @SerialName("continue_with") val continueWith: List = emptyList(), + // Nullable: Kratos sends "continue_with": null on a plain login. + @SerialName("continue_with") val continueWith: List? = null, ) /** @@ -162,7 +168,8 @@ data class KratosRegistrationSuccess( val session: KratosSession? = null, @SerialName("session_token") val sessionToken: String? = null, val identity: KratosIdentity? = null, - @SerialName("continue_with") val continueWith: List = emptyList(), + // Nullable: Kratos may serialise this as an explicit null. + @SerialName("continue_with") val continueWith: List? = null, ) /** diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt index efb63e3..5e6af8f 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt @@ -49,6 +49,11 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { ignoreUnknownKeys = true isLenient = true encodeDefaults = true + // Kratos serialises empty list/object fields as explicit `null` rather + // than omitting them or sending `[]`. Without this, a non-nullable field + // with a default (e.g. ui.nodes, ui.messages) throws on a null value. + // coerceInputValues falls back to the declared default in that case. + coerceInputValues = true } /** @@ -69,9 +74,9 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { * is rejected. [verifyEmail] reads the persisted id and posts to it * directly. */ - private fun capturePendingVerificationFlow(continueWith: List) { + private fun capturePendingVerificationFlow(continueWith: List?) { val flowId = continueWith - .firstOrNull { it.action == "show_verification_ui" } + ?.firstOrNull { it.action == "show_verification_ui" } ?.flow?.id if (!flowId.isNullOrBlank()) { DataManager.setPendingVerificationFlowId(flowId) diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/AuthFlowDecodeTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/AuthFlowDecodeTest.kt new file mode 100644 index 0000000..0ce47ac --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/AuthFlowDecodeTest.kt @@ -0,0 +1,176 @@ +package com.tt.honeyDue.network + +import com.tt.honeyDue.models.KratosFlow +import com.tt.honeyDue.models.KratosLoginSuccess +import com.tt.honeyDue.models.User +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Decode tests against REAL response bodies captured from the live cluster + * (api.myhoneydue.com / auth.myhoneydue.com) on 2026-06-04 for the full + * password sign-up flow: register -> login -> /auth/me -> verification. + * + * These exist because hand-written test JSON missed the production shape that + * crashed the app ("continue_with": null on login). Each body below is the + * verbatim server response; each test decodes it with the SAME Json config the + * client uses for that call, so a shape regression fails here, not on a device. + */ +class AuthFlowDecodeTest { + + // Mirrors AuthApi's Json (Kratos self-service responses). + private val kratosJson = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + coerceInputValues = true + } + + // Mirrors ApiClient's Json (honeyDue API responses via response.body()). + private val apiJson = Json { + ignoreUnknownKeys = true + isLenient = true + } + + // --- Real body: POST {kratos}/self-service/login (password) --- + private val loginSuccessBody = """ + { + "session_token": "ory_st_sYRvNvjO0PnfTxaTsClAGTW15Xr73tNV", + "session": { + "id": "a32482d4-3319-4b7f-9fcf-454dda585214", + "active": true, + "expires_at": "2026-07-04T00:25:58.742590696Z", + "authenticated_at": "2026-06-04T00:25:58.742590696Z", + "authenticator_assurance_level": "aal1", + "authentication_methods": [ + { "method": "password", "aal": "aal1", "completed_at": "2026-06-04T00:25:58.742413956Z" } + ], + "issued_at": "2026-06-04T00:25:58.742590696Z", + "identity": { + "id": "9f80cd43-051b-41b4-b2df-3eeabcc7d9e7", + "schema_id": "honeydue", + "schema_url": "https://auth.myhoneydue.com/schemas/aG9uZXlkdWU", + "state": "active", + "state_changed_at": "2026-06-04T00:25:57.465158Z", + "traits": { "name": { "last": "Decode", "first": "Qa" }, "email": "qa-decode@example.com" }, + "verifiable_addresses": [ + { "id": "c5ee8e49-3ee2-490a-b428-1247d03e5b74", "value": "qa-decode@example.com", + "verified": false, "via": "email", "status": "pending", + "created_at": "2026-06-04T00:25:57.483052Z", "updated_at": "2026-06-04T00:25:57.483052Z" } + ], + "recovery_addresses": [ + { "id": "a1ef4f76-7721-4d31-af23-9a301f6c7ae5", "value": "qa-decode@example.com", + "via": "email", "created_at": "2026-06-04T00:25:57.494125Z", "updated_at": "2026-06-04T00:25:57.494125Z" } + ], + "metadata_public": null, + "created_at": "2026-06-04T00:25:57.471734Z", + "updated_at": "2026-06-04T00:25:57.471734Z", + "organization_id": null + }, + "devices": [ + { "id": "b4a8c4cb-e0c7-4e1d-bee6-67c2fb8f060c", "ip_address": "47.185.183.191", + "user_agent": "curl/8.7.1", "location": "US" } + ] + }, + "continue_with": null + } + """.trimIndent() + + // --- Real body: GET {api}/auth/me --- + private val authMeBody = """ + { + "id": 69, + "username": "qa-decode@example.com", + "email": "qa-decode@example.com", + "first_name": "Qa", + "last_name": "Decode", + "is_active": true, + "date_joined": "2026-06-04T00:25:59.267898Z", + "profile": { "id": 42, "user_id": 69, "verified": true, "bio": "", "phone_number": "", "profile_picture": "" }, + "auth_provider": "kratos" + } + """.trimIndent() + + // --- Real body: GET {kratos}/self-service/verification/api --- + private val verificationInitBody = """ + { + "id": "46aed4a9-affd-4c81-8f1c-bbdd07039810", + "type": "api", + "expires_at": "2026-06-04T01:25:59.567346745Z", + "issued_at": "2026-06-04T00:25:59.567346745Z", + "request_url": "https://auth.myhoneydue.com/self-service/verification/api", + "active": "code", + "ui": { + "action": "https://auth.myhoneydue.com/self-service/verification?flow=46aed4a9-affd-4c81-8f1c-bbdd07039810", + "method": "POST", + "nodes": [ + { "type": "input", "group": "code", + "attributes": { "name": "email", "type": "email", "required": true, "disabled": false, "node_type": "input" }, + "messages": [], "meta": { "label": { "id": 1070007, "text": "Email", "type": "info" } } }, + { "type": "input", "group": "code", + "attributes": { "name": "method", "type": "submit", "value": "code", "disabled": false, "node_type": "input" }, + "messages": [], "meta": {} } + ] + } + } + """.trimIndent() + + // --- Real body: POST verification (correct code) --- + private val verificationPassedBody = """ + { + "id": "46aed4a9-affd-4c81-8f1c-bbdd07039810", + "type": "api", + "state": "passed_challenge", + "ui": { + "action": "https://auth.myhoneydue.com/self-service/verification?flow=46aed4a9-affd-4c81-8f1c-bbdd07039810", + "method": "POST", + "nodes": [], + "messages": [ { "id": 1080002, "text": "You successfully verified your email address.", "type": "success" } ] + }, + "continue_with": null + } + """.trimIndent() + + @Test + fun decodesRealLoginSuccess() { + val r = kratosJson.decodeFromString(KratosLoginSuccess.serializer(), loginSuccessBody) + assertEquals("ory_st_sYRvNvjO0PnfTxaTsClAGTW15Xr73tNV", r.sessionToken) + assertEquals("qa-decode@example.com", r.session.identity?.traits?.email) + assertEquals("Qa", r.session.identity?.traits?.name?.first) + assertNull(r.continueWith, "production login sends continue_with: null") + // verifiable address present + unverified, extra fields ignored + val addr = r.session.identity?.verifiableAddresses?.firstOrNull() + assertEquals("qa-decode@example.com", addr?.value) + assertFalse(addr?.verified ?: true) + } + + @Test + fun decodesRealAuthMeUser() { + val u = apiJson.decodeFromString(User.serializer(), authMeBody) + assertEquals(69, u.id) + assertEquals("qa-decode@example.com", u.email) + assertEquals("Qa Decode", u.displayName) + // verified comes through the profile for /auth/me + assertTrue(u.isVerified, "profile.verified=true must surface as User.isVerified") + } + + @Test + fun decodesRealVerificationInitFlow() { + val f = kratosJson.decodeFromString(KratosFlow.serializer(), verificationInitBody) + assertEquals("46aed4a9-affd-4c81-8f1c-bbdd07039810", f.id) + assertTrue(f.ui.action.endsWith("flow=46aed4a9-affd-4c81-8f1c-bbdd07039810")) + assertTrue(f.ui.nodes.isNotEmpty()) + } + + @Test + fun decodesRealVerificationPassed() { + val f = kratosJson.decodeFromString(KratosFlow.serializer(), verificationPassedBody) + assertEquals("passed_challenge", f.state) + assertNull(f.continueWith) + assertTrue(f.ui.messages.any { it.text.contains("successfully verified") }) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/KratosDecodeTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/KratosDecodeTest.kt new file mode 100644 index 0000000..e7550b8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/KratosDecodeTest.kt @@ -0,0 +1,86 @@ +package com.tt.honeyDue.network + +import com.tt.honeyDue.models.KratosLoginSuccess +import com.tt.honeyDue.models.KratosRegistrationSuccess +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Regression tests for Kratos response decoding. + * + * Bug (2026-06-03): the login that follows admin-create registration failed on + * the device with "Unexpected JSON token ... Expected start of the array '[' + * but had 'n' ... at path: $.continue_with". Kratos serialises an empty + * `continue_with` as an explicit `null`, not `[]` or an absent key, so a + * non-nullable `List` field threw. These tests pin the decode against the exact + * shapes Kratos returns. + */ +class KratosDecodeTest { + + // Mirrors the Json config used by AuthApi. + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + coerceInputValues = true + } + + @Test + fun decodesLoginSuccessWithNullContinueWith() { + // The exact shape from a plain login (post admin-create): session + + // token, continue_with explicitly null. + val body = """ + { + "session": { + "id": "11111111-1111-1111-1111-111111111111", + "active": true, + "identity": { "id": "22222222-2222-2222-2222-222222222222" } + }, + "session_token": "ory_st_example", + "continue_with": null + } + """.trimIndent() + + val decoded = json.decodeFromString(KratosLoginSuccess.serializer(), body) + + assertEquals("ory_st_example", decoded.sessionToken) + assertNull(decoded.continueWith, "null continue_with must decode to null, not throw") + } + + @Test + fun decodesLoginSuccessWithAbsentContinueWith() { + val body = """ + { + "session": { "id": "s", "active": true, "identity": { "id": "i" } }, + "session_token": "tok" + } + """.trimIndent() + val decoded = json.decodeFromString(KratosLoginSuccess.serializer(), body) + assertEquals("tok", decoded.sessionToken) + } + + @Test + fun decodesRegistrationSuccessWithContinueWithItems() { + val body = """ + { + "session_token": "tok", + "continue_with": [ + { "action": "show_verification_ui", "flow": { "id": "flow-123" } } + ] + } + """.trimIndent() + val decoded = json.decodeFromString(KratosRegistrationSuccess.serializer(), body) + assertTrue(decoded.continueWith?.any { it.action == "show_verification_ui" } == true) + assertEquals("flow-123", decoded.continueWith?.first()?.flow?.id) + } + + @Test + fun decodesRegistrationSuccessWithNullContinueWith() { + val body = """{ "session_token": "tok", "continue_with": null }""" + val decoded = json.decodeFromString(KratosRegistrationSuccess.serializer(), body) + assertNull(decoded.continueWith) + } +} diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 1826e42..5983a0d 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -22047,6 +22047,9 @@ }, "Remove User" : { + }, + "Resend code" : { + }, "Reset All Tasks" : { "comment" : "A button label that resets all tasks.",