Fix continue_with:null decode crash + add auth decode/integration tests
Android UI Tests / ui-tests (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-06-03 22:30:48 -05:00
parent 7c892d2bb6
commit 6058013951
6 changed files with 350 additions and 5 deletions
@@ -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)
}
}
@@ -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<KratosContinueWith> = emptyList(),
@SerialName("continue_with") val continueWith: List<KratosContinueWith>? = 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<KratosContinueWith> = emptyList(),
// Nullable: Kratos sends "continue_with": null on a plain login.
@SerialName("continue_with") val continueWith: List<KratosContinueWith>? = 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<KratosContinueWith> = emptyList(),
// Nullable: Kratos may serialise this as an explicit null.
@SerialName("continue_with") val continueWith: List<KratosContinueWith>? = null,
)
/**
@@ -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<KratosContinueWith>) {
private fun capturePendingVerificationFlow(continueWith: List<KratosContinueWith>?) {
val flowId = continueWith
.firstOrNull { it.action == "show_verification_ui" }
?.firstOrNull { it.action == "show_verification_ui" }
?.flow?.id
if (!flowId.isNullOrBlank()) {
DataManager.setPendingVerificationFlowId(flowId)
@@ -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") })
}
}
@@ -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)
}
}