Fix continue_with:null decode crash + add auth decode/integration tests
Android UI Tests / ui-tests (push) Has been cancelled
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:
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user