diff --git a/api-schema/src/main/java/org/stellar/anchor/api/event/AnchorEvent.java b/api-schema/src/main/java/org/stellar/anchor/api/event/AnchorEvent.java index b36b4ccb1a..029149b1fe 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/event/AnchorEvent.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/event/AnchorEvent.java @@ -18,8 +18,13 @@ public class AnchorEvent { Type type; String id; String sep; + + @SerializedName("client_name") String clientName; + + @SerializedName("client_domain") String clientDomain; + GetTransactionResponse transaction; GetQuoteResponse quote; Sep12GetCustomerResponse customer; diff --git a/api-schema/src/main/java/org/stellar/anchor/api/platform/GetTransactionResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/GetTransactionResponse.java index 7ea7112d32..8483b4b454 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/platform/GetTransactionResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/GetTransactionResponse.java @@ -1,10 +1,10 @@ package org.stellar.anchor.api.platform; +import com.google.gson.annotations.SerializedName; import java.util.Map; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; -import org.stellar.anchor.api.asset.AssetInfo; /** * The response body of the GET /transactions/{id} endpoint of the Platform API. @@ -17,6 +17,7 @@ @SuperBuilder @NoArgsConstructor public class GetTransactionResponse extends PlatformTransactionData { + @SerializedName("funding_method") String fundingMethod; /** @@ -26,13 +27,4 @@ public class GetTransactionResponse extends PlatformTransactionData { * cross-reference {@link PlatformTransactionData#getRequiredInfoUpdates()}. */ Map fields; - - /** - * The real per-field metadata (description/choices/optional) backing {@link - * PlatformTransactionData#getRequiredInfoUpdates()}, keyed by field name -- populated from the - * transaction's own stored Sep31Info.Fields, so a status callback or client status API consumer - * gets the actual metadata instead of one synthesized from the field name. Currently only - * populated for SEP-31. - */ - Map requiredInfoUpdatesFields; } diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep10/ChallengeRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep10/ChallengeRequest.java index 748e7768b0..74e887f456 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep10/ChallengeRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep10/ChallengeRequest.java @@ -1,6 +1,7 @@ package org.stellar.anchor.api.sep.sep10; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; import lombok.Builder; import lombok.Data; @@ -17,8 +18,10 @@ public class ChallengeRequest { String memo; @JsonProperty("home_domain") + @SerializedName("home_domain") String homeDomain; @JsonProperty("client_domain") + @SerializedName("client_domain") String clientDomain; } diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ChallengeRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ChallengeRequest.java index bf206104cd..b1eb9ffc17 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ChallengeRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ChallengeRequest.java @@ -1,6 +1,7 @@ package org.stellar.anchor.api.sep.sep45; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; import lombok.Builder; import lombok.Data; @@ -16,8 +17,10 @@ public class ChallengeRequest { private String account; @JsonProperty("home_domain") + @SerializedName("home_domain") private String homeDomain; @JsonProperty("client_domain") + @SerializedName("client_domain") private String clientDomain; } diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ChallengeResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ChallengeResponse.java index 5a39626cf6..6851eabc13 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ChallengeResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ChallengeResponse.java @@ -1,6 +1,7 @@ package org.stellar.anchor.api.sep.sep45; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; import lombok.Builder; import lombok.Data; @@ -14,8 +15,10 @@ @Data public class ChallengeResponse { @JsonProperty("authorization_entries") + @SerializedName("authorization_entries") private String authorizationEntries; @JsonProperty("network_passphrase") + @SerializedName("network_passphrase") private String networkPassphrase; } diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ValidationRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ValidationRequest.java index d549c6ff5e..fc00f8ed14 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ValidationRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep45/ValidationRequest.java @@ -1,6 +1,7 @@ package org.stellar.anchor.api.sep.sep45; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; import lombok.Builder; import lombok.Data; @@ -14,5 +15,6 @@ @Data public class ValidationRequest { @JsonProperty("authorization_entries") + @SerializedName("authorization_entries") private String authorizationEntries; } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 07e4362408..58c93b1810 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(project(":lib-util")) testImplementation(libs.coroutines.core) + testImplementation(libs.jackson.annotations) testImplementation(libs.okhttp3.mockserver) testImplementation(libs.servlet.api) testImplementation(libs.slf4j.api) diff --git a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java index 4b0f919f19..3dc7cc6243 100644 --- a/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java +++ b/core/src/main/java/org/stellar/anchor/sep45/Sep45Service.java @@ -190,7 +190,8 @@ public ValidationResponse validate(ValidationRequest request) throws AnchorExcep SorobanAuthorizationEntries authEntries; try { authEntries = SorobanAuthorizationEntries.fromXdrBase64(request.getAuthorizationEntries()); - } catch (IOException e) { + } catch (IOException | IllegalArgumentException e) { + // a value that is not base64 at all fails with IllegalArgumentException, not IOException throw new BadRequestException("Failed to decode auth entries"); } SorobanAuthorizationEntry[] entries = authEntries.getSorobanAuthorizationEntries(); diff --git a/core/src/test/kotlin/org/stellar/anchor/util/JacksonOnlyDtoGuardTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/JacksonOnlyDtoGuardTest.kt new file mode 100644 index 0000000000..ecd7d2c737 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/util/JacksonOnlyDtoGuardTest.kt @@ -0,0 +1,196 @@ +package org.stellar.anchor.util + +import com.google.gson.annotations.SerializedName +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider + +/** + * The servers serialize every body with Gson, which ignores `@JsonProperty`: a field annotated for + * Jackson only goes out under its raw Java name. This guard fails when a DTO in + * `org.stellar.anchor.api` carries a `@JsonProperty` without a `@SerializedName` of the same value. + * Only `value` counts: Gson writes a field under `value` and merely also reads `alternate`. The + * scan reads Jackson's annotation by name, while the synthetic classes of the self-test use it + * directly, hence the test-only `jackson-annotations` dependency in `core/build.gradle.kts`. + * + * The complement is a field with no annotation of either kind, which Gson writes under its Java + * name: no serialized field may have a wire key with an upper-case letter, apart from the named + * exceptions in [camelCaseWireKeys]. + */ +class JacksonOnlyDtoGuardTest { + private val jsonProperty = "com.fasterxml.jackson.annotation.JsonProperty" + + private fun jacksonName(field: Field): String? = + field.annotations + .firstOrNull { it.annotationClass.java.name == jsonProperty } + ?.let { it.annotationClass.java.getMethod("value").invoke(it) as String } + + private fun serializedNames(field: Field): Set = + field.getAnnotation(SerializedName::class.java)?.let { setOf(it.value) } ?: emptySet() + + private fun violations(classes: Collection>): List = + classes.flatMap { cls -> + cls.declaredFields + .filter { !Modifier.isStatic(it.modifiers) && !Modifier.isTransient(it.modifiers) } + .mapNotNull { field -> + val name = jacksonName(field) + if (name != null && name !in serializedNames(field)) { + "${cls.name}.${field.name} has @JsonProperty(\"$name\") but no @SerializedName(\"$name\")" + } else null + } + } + + // the request DTOs overwrite requestClientIpAddress from the connection and never read it from + // the body; SepDepositInfo is only handed between the platform's own classes + private val camelCaseWireKeys = + setOf( + "org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest.requestClientIpAddress", + "org.stellar.anchor.api.sep.sep6.StartDepositRequest.requestClientIpAddress", + "org.stellar.anchor.api.sep.sep6.StartDepositExchangeRequest.requestClientIpAddress", + "org.stellar.anchor.api.sep.sep6.StartWithdrawRequest.requestClientIpAddress", + "org.stellar.anchor.api.sep.sep6.StartWithdrawExchangeRequest.requestClientIpAddress", + "org.stellar.anchor.api.shared.SepDepositInfo.stellarAddress", + ) + + private fun scan(everyClass: Boolean): List> { + val scanner = + object : ClassPathScanningCandidateComponentProvider(false) { + // every class counts, not only concrete top-level ones + override fun isCandidateComponent(beanDefinition: AnnotatedBeanDefinition) = + everyClass || super.isCandidateComponent(beanDefinition) + } + scanner.addIncludeFilter { _, _ -> true } + return scanner.findCandidateComponents("org.stellar.anchor.api").map { + Class.forName(it.beanClassName, false, javaClass.classLoader) + } + } + + private fun apiClasses(): List> = scan(true) + + // Gson never reads a Lombok builder, writes an enum by constant name, and no exception reaches a + // response body as itself + private fun isSerialized(cls: Class<*>): Boolean = + !cls.isEnum && + !cls.isInterface && + !cls.isAnonymousClass && + !Throwable::class.java.isAssignableFrom(cls) && + !(cls.enclosingClass != null && cls.simpleName.endsWith("Builder")) + + private fun camelCaseKeys(classes: Collection>): List = + classes + .filter { isSerialized(it) } + .flatMap { cls -> + cls.declaredFields + .filter { + !Modifier.isStatic(it.modifiers) && + !Modifier.isTransient(it.modifiers) && + !it.isSynthetic + } + .filter { field -> + val key = field.getAnnotation(SerializedName::class.java)?.value ?: field.name + key.any { it.isUpperCase() } && "${cls.name}.${field.name}" !in camelCaseWireKeys + } + .map { "${cls.name}.${it.name}" } + } + + // WIRE-10 + @Test + fun `test no DTO field carries a JsonProperty without a matching SerializedName`() { + val classes = apiClasses() + + // the scan must see the DTOs, or an empty result would pass for the wrong reason + val annotated = + classes + .flatMap { cls -> cls.declaredFields.map { "${cls.name}.${it.name}" to jacksonName(it) } } + .filter { it.second != null } + .map { it.first } + val sentinels = + listOf( + "org.stellar.anchor.api.sep.sep45.ValidationRequest.authorizationEntries", + "org.stellar.anchor.api.sep.sep10.ChallengeRequest.clientDomain", + "org.stellar.anchor.api.shared.Refunds.amountRefunded", + "org.stellar.anchor.api.platform.GetQuoteResponse.sellAmount", + ) + assertEquals(emptyList(), sentinels.filter { it !in annotated }) + + assertEquals(emptyList(), violations(classes)) + } + + // WIRE-10 + @Test + fun `test the guard names a class and field that carry only a JsonProperty`() { + class JacksonOnly { + @com.fasterxml.jackson.annotation.JsonProperty("home_domain") var homeDomain: String? = null + } + class Both { + @com.fasterxml.jackson.annotation.JsonProperty("home_domain") + @SerializedName("home_domain") + var homeDomain: String? = null + } + class Alternate { + @com.fasterxml.jackson.annotation.JsonProperty("home_domain") + @SerializedName(value = "homeDomain", alternate = ["home_domain"]) + var homeDomain: String? = null + } + class Mismatched { + @com.fasterxml.jackson.annotation.JsonProperty("home_domain") + @SerializedName("homeDomain") + var homeDomain: String? = null + } + + val found = + violations( + listOf( + JacksonOnly::class.java, + Both::class.java, + Alternate::class.java, + Mismatched::class.java, + ) + ) + + assertEquals(3, found.size, found.toString()) + assertTrue(found[0].contains("JacksonOnly.homeDomain"), found[0]) + assertTrue(found[1].contains("Alternate.homeDomain"), found[1]) + assertTrue(found[2].contains("Mismatched.homeDomain"), found[2]) + } + + // the scan must reach what Spring's default filter skips (abstract, nested and builder classes), + // or a DTO declared that way goes unchecked while the sentinels above still pass + @Test + fun `test the scan reaches classes beyond Spring's default candidate filter`() { + val widened = apiClasses().map { it.name }.toSet() + val default = scan(false).map { it.name }.toSet() + + assertTrue(default.all { it in widened }) + assertTrue(widened.size > default.size, "widened=${widened.size} default=${default.size}") + } + + // WIRE-12 + @Test + fun `test no serialized DTO field is published under a camelCase wire key`() { + assertEquals(emptyList(), camelCaseKeys(apiClasses())) + } + + // WIRE-12 + @Test + fun `test the camelCase check names a field with no annotation`() { + class Bare { + var payoutRailHint: String? = null + } + class Named { + @SerializedName("payout_rail_hint") var payoutRailHint: String? = null + } + class PlainKey { + var amount: String? = null + } + + val found = camelCaseKeys(listOf(Bare::class.java, Named::class.java, PlainKey::class.java)) + + assertEquals(1, found.size, found.toString()) + assertTrue(found[0].contains("Bare.payoutRailHint"), found[0]) + } +} diff --git a/core/src/test/kotlin/org/stellar/anchor/util/TransactionMapperTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/TransactionMapperTest.kt index 6fdbcd40c8..78beafc7fc 100644 --- a/core/src/test/kotlin/org/stellar/anchor/util/TransactionMapperTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/util/TransactionMapperTest.kt @@ -168,7 +168,7 @@ class TransactionMapperTest { // them directly rather than switching this whole builder chain to GetTransactionResponse. expectedJsonObject.add("fields", gson.toJsonTree(sepTxn.fields)) expectedJsonObject.add( - "requiredInfoUpdatesFields", + "required_info_updates_fields", gson.toJsonTree(sepTxn.requiredInfoUpdates.transaction), ) val expected = gson.toJson(expectedJsonObject) @@ -176,6 +176,49 @@ class TransactionMapperTest { JSONAssert.assertEquals(expected, actual, true) } + // WIRE-04 + @Test + fun `test SEP-31 transaction mapping publishes the per-field metadata under one key`() { + val metadata = mapOf("bank_name" to AssetInfo.Field("The bank's name", null, false)) + val sepTxn = + PojoSep31Transaction().apply { + id = UUID.randomUUID().toString() + status = "pending_transaction_info_update" + amountIn = "100.0" + amountInAsset = "USDC" + amountOut = "100.0" + amountOutAsset = "USD" + feeDetails = FeeDetails("10.0", "USD") + fromAccount = "fromAccount" + toAccount = "toAccount" + startedAt = Instant.now() + updatedAt = Instant.now() + amountExpected = "100.0" + receiverId = "receiverId" + senderId = "senderId" + requiredInfoUpdates = Sep31Info.Fields().apply { transaction = metadata } + } + + val response = TransactionMapper.toGetTransactionResponse(sepTxn) + val json = gson.toJsonTree(response).asJsonObject + + assertEquals(metadata, response.requiredInfoUpdatesFields) + assertEquals( + "The bank's name", + json + .getAsJsonObject("required_info_updates_fields") + .getAsJsonObject("bank_name") + .get("description") + .asString, + ) + assertEquals( + listOf("required_info_updates_fields"), + json.keySet().filter { + it in setOf("required_info_updates_fields", "requiredInfoUpdatesFields") + }, + ) + } + @Test fun `test SEP-31 transaction mapping uses refund memo override when specified`() { val sepTxn = @@ -492,7 +535,7 @@ class TransactionMapperTest { val jsonObject = gson.fromJson(jsonString, JsonObject::class.java) // Add the "funding_method" field - jsonObject.addProperty("fundingMethod", sepTxn.type) + jsonObject.addProperty("funding_method", sepTxn.type) // PlatformTransactionData's builder has no slot for `fields` (it lives on // GetTransactionResponse instead), so add it directly rather than switching this whole // builder chain to GetTransactionResponse. diff --git a/core/src/test/kotlin/org/stellar/anchor/util/WireKeyNamingTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/WireKeyNamingTest.kt new file mode 100644 index 0000000000..34c6bec56d --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/util/WireKeyNamingTest.kt @@ -0,0 +1,193 @@ +package org.stellar.anchor.util + +import com.google.gson.JsonObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.stellar.anchor.api.asset.AssetInfo +import org.stellar.anchor.api.event.AnchorEvent +import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.sep.sep10.ChallengeRequest as Sep10ChallengeRequest +import org.stellar.anchor.api.sep.sep45.ChallengeRequest as Sep45ChallengeRequest +import org.stellar.anchor.api.sep.sep45.ChallengeResponse +import org.stellar.anchor.api.sep.sep45.ValidationRequest + +/** + * Pins the JSON keys the shipped DTOs use on the wire. The servers hand every body to + * `GsonUtils.getInstance()`, which ignores Jackson annotations and uses the raw Java name for a + * field without `@SerializedName`, so these tests read the raw keys instead of round-tripping + * through the same DTO (a wrong key is symmetric there and goes unnoticed). + */ +class WireKeyNamingTest { + private val gson = GsonUtils.getInstance() + + private fun toJson(obj: Any): JsonObject = gson.toJsonTree(obj).asJsonObject + + // WIRE-01, WIRE-03 + @Test + fun `test fundingMethod is serialized as funding_method`() { + val json = toJson(GetTransactionResponse.builder().fundingMethod("SEPA").build()) + + assertEquals("SEPA", json.get("funding_method").asString) + assertFalse(json.has("fundingMethod"), "keys: ${json.keySet()}") + } + + // WIRE-02, WIRE-03 + @Test + fun `test requiredInfoUpdatesFields is serialized as required_info_updates_fields`() { + val json = + toJson( + GetTransactionResponse.builder() + .requiredInfoUpdatesFields( + mapOf("bank_name" to AssetInfo.Field("The bank's name", listOf("a", "b"), true)) + ) + .build() + ) + + val field = json.getAsJsonObject("required_info_updates_fields").getAsJsonObject("bank_name") + assertEquals("The bank's name", field.get("description").asString) + assertEquals(listOf("a", "b"), field.getAsJsonArray("choices").map { it.asString }) + assertTrue(field.get("optional").asBoolean) + assertFalse(json.has("requiredInfoUpdatesFields"), "keys: ${json.keySet()}") + } + + // WIRE-06 + @Test + fun `test GetTransactionResponse is populated from funding_method and required_info_updates_fields`() { + val response = + gson.fromJson( + """ + { + "funding_method": "SWIFT", + "required_info_updates_fields": {"bank_name": {"description": "d", "optional": false}} + } + """, + GetTransactionResponse::class.java, + ) + + assertEquals("SWIFT", response.fundingMethod) + assertEquals("d", response.requiredInfoUpdatesFields["bank_name"]!!.description) + } + + // WIRE-07 + @Test + fun `test a fully populated GetTransactionResponse serializes exactly the documented keys`() { + val full = + """ + { + "id": "tx1", "sep": "31", "kind": "receive", "status": "completed", "type": "bank_account", + "amount_expected": {"amount": "1", "asset": "USD"}, + "amount_in": {"amount": "1", "asset": "USD"}, + "amount_out": {"amount": "1", "asset": "USD"}, + "fee_details": {"total": "1", "asset": "USD"}, + "quote_id": "q", "started_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", + "completed_at": "2024-01-01T00:00:00Z", "user_action_required_by": "2024-01-01T00:00:00Z", + "transfer_received_at": "2024-01-01T00:00:00Z", "message": "m", "refunds": {}, + "stellar_transactions": [], "source_account": "a", "destination_account": "b", + "external_transaction_id": "e", "memo": "m", "memo_type": "text", "refund_memo": "m", + "refund_memo_type": "text", "withdraw_anchor_account": "w", "client_domain": "d", + "client_name": "n", "customers": {"sender": {"id": "s"}}, "creator": {"id": "c"}, + "required_info_message": "r", "required_info_updates": ["f"], + "required_info_updates_fields": {"f": {"description": "d"}}, + "request_client_ip_address": "1.2.3.4", "instructions": {"i": {"value": "v"}}, + "funding_method": "SEPA", "fields": {"f": "v"} + } + """ + + val json = toJson(gson.fromJson(full, GetTransactionResponse::class.java)) + + assertEquals( + gson.fromJson(full, JsonObject::class.java).keySet().sorted(), + json.keySet().sorted(), + ) + } + + // edge cases: null keys are omitted + @Test + fun `test null fundingMethod and requiredInfoUpdatesFields are omitted`() { + val json = toJson(GetTransactionResponse.builder().id("tx1").build()) + + assertFalse(json.has("funding_method"), "keys: ${json.keySet()}") + assertFalse(json.has("required_info_updates_fields"), "keys: ${json.keySet()}") + } + + // the event queue carries this payload, and its transaction already uses client_name + @Test + fun `test AnchorEvent publishes client_name and client_domain`() { + val json = + toJson(AnchorEvent.builder().clientName("wallet").clientDomain("wallet.example").build()) + + assertEquals("wallet", json.get("client_name").asString) + assertEquals("wallet.example", json.get("client_domain").asString) + assertFalse(json.has("clientName"), "keys: ${json.keySet()}") + assertFalse(json.has("clientDomain"), "keys: ${json.keySet()}") + val read = gson.fromJson(json, AnchorEvent::class.java) + assertEquals("wallet", read.clientName) + assertEquals("wallet.example", read.clientDomain) + } + + // WIRE-08 + @Test + fun `test SEP-45 ChallengeResponse is serialized with the spec keys`() { + val json = + toJson( + ChallengeResponse.builder() + .authorizationEntries("entries") + .networkPassphrase("passphrase") + .build() + ) + + assertEquals(setOf("authorization_entries", "network_passphrase"), json.keySet()) + assertEquals("entries", json.get("authorization_entries").asString) + assertEquals("passphrase", json.get("network_passphrase").asString) + } + + // WIRE-08 + @Test + fun `test SEP-45 ValidationRequest is populated from authorization_entries`() { + val request = + gson.fromJson("""{"authorization_entries": "entries"}""", ValidationRequest::class.java) + + assertEquals("entries", request.authorizationEntries) + } + + // WIRE-11 + @Test + fun `test SEP-45 and SEP-10 ChallengeRequest read home_domain and client_domain`() { + val body = + """{"account": "G1", "home_domain": "home.example", "client_domain": "client.example"}""" + + val sep45 = gson.fromJson(body, Sep45ChallengeRequest::class.java) + val sep10 = gson.fromJson(body, Sep10ChallengeRequest::class.java) + + assertEquals("home.example", sep45.homeDomain) + assertEquals("client.example", sep45.clientDomain) + assertEquals("home.example", sep10.homeDomain) + assertEquals("client.example", sep10.clientDomain) + } + + // WIRE-11 + @Test + fun `test SEP-45 and SEP-10 ChallengeRequest write home_domain and client_domain`() { + val sep45 = + toJson( + Sep45ChallengeRequest.builder() + .account("G1") + .homeDomain("home.example") + .clientDomain("client.example") + .build() + ) + val sep10 = + toJson( + Sep10ChallengeRequest.builder() + .account("G1") + .homeDomain("home.example") + .clientDomain("client.example") + .build() + ) + + assertEquals(setOf("account", "home_domain", "client_domain"), sep45.keySet()) + assertEquals(setOf("account", "home_domain", "client_domain"), sep10.keySet()) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/Sep45ControllerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/Sep45ControllerTest.kt new file mode 100644 index 0000000000..84f28ad49a --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/Sep45ControllerTest.kt @@ -0,0 +1,151 @@ +package org.stellar.anchor.platform.controller.sep + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType +import org.springframework.http.converter.json.GsonHttpMessageConverter +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.stellar.anchor.api.sep.sep45.ChallengeResponse +import org.stellar.anchor.api.sep.sep45.ValidationRequest +import org.stellar.anchor.api.sep.sep45.ValidationResponse +import org.stellar.anchor.config.SecretConfig +import org.stellar.anchor.config.StellarNetworkConfig +import org.stellar.anchor.sep45.Sep45Service +import org.stellar.anchor.util.GsonUtils +import org.stellar.sdk.KeyPair +import org.stellar.sdk.Network + +/** + * The transport of SEP-45 `/auth`: which keys a request is read from and a response is written + * under. The servers use `GsonUtils.getInstance()` as the only JSON converter, so the controller is + * set up standalone with the same converter (see `Sep1ControllerTest`); a body is sent as raw JSON, + * never through the DTO, because a wrong key is symmetric when a client shares the DTO. + */ +class Sep45ControllerTest { + private val spec = "spec-entries" + + private fun mvc(service: Sep45Service): MockMvc = + MockMvcBuilders.standaloneSetup(Sep45Controller(service)) + .setMessageConverters(GsonHttpMessageConverter(GsonUtils.getInstance())) + .build() + + // a service that runs the real validate(), which rejects an empty entries value first + private fun realService(): Sep45Service { + val secretConfig = mockk() + val networkConfig = mockk() + every { secretConfig.sep10SigningSeed } returns String(KeyPair.random().secretSeed) + every { networkConfig.stellarNetworkPassphrase } returns Network.TESTNET.networkPassphrase + return Sep45Service( + networkConfig, + secretConfig, + mockk(), + mockk(), + mockk(), + mockk(), + mockk(relaxed = true), + ) + } + + // WIRE-08 + @Test + fun `test GET auth answers the challenge under the spec keys`() { + val service = mockk() + every { service.getChallenge(any()) } returns + ChallengeResponse.builder() + .authorizationEntries("entries") + .networkPassphrase("passphrase") + .build() + + mvc(service) + .perform(get("/sep45/auth").param("account", "CA")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.authorization_entries").value("entries")) + .andExpect(jsonPath("$.network_passphrase").value("passphrase")) + .andExpect(jsonPath("$.authorizationEntries").doesNotExist()) + .andExpect(jsonPath("$.networkPassphrase").doesNotExist()) + } + + // WIRE-09 + @Test + fun `test POST auth with a JSON body hands the spec key to the service`() { + val service = mockk() + val request = slot() + every { service.validate(capture(request)) } returns + ValidationResponse.builder().token("jwt").build() + + mvc(service) + .perform( + post("/sep45/auth") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"authorization_entries": "$spec"}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.token").value("jwt")) + + assertEquals(spec, request.captured.authorizationEntries) + } + + // WIRE-09 + @Test + fun `test POST auth with a form body hands the spec key to the service`() { + val service = mockk() + val request = slot() + every { service.validate(capture(request)) } returns + ValidationResponse.builder().token("jwt").build() + + mvc(service) + .perform( + post("/sep45/auth") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("authorization_entries", spec) + ) + .andExpect(status().isOk) + + assertEquals(spec, request.captured.authorizationEntries) + } + + // WIRE-09 + @Test + fun `test POST auth with a JSON body without authorization_entries answers 400`() { + mvc(realService()) + .perform(post("/sep45/auth").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("authorization_entries is required")) + } + + // a value that is not base64 is a client error, not a 500 + @Test + fun `test POST auth with an authorization_entries value that is not base64 answers 400`() { + for (value in listOf("spec-entries", " ", "\\t", "!!!", "AA=A", "not base64!")) { + mvc(realService()) + .perform( + post("/sep45/auth") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"authorization_entries": "$value"}""") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("Failed to decode auth entries")) + } + } + + // WIRE-09: the camelCase name is not an accepted key + @Test + fun `test POST auth with only the camelCase key answers 400`() { + mvc(realService()) + .perform( + post("/sep45/auth") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"authorizationEntries": "$spec"}""") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("authorization_entries is required")) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/event/ClientStatusCallbackHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/event/ClientStatusCallbackHandlerTest.kt index 71cf74f1a7..feaf3dd029 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/event/ClientStatusCallbackHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/event/ClientStatusCallbackHandlerTest.kt @@ -53,6 +53,7 @@ import org.stellar.anchor.sep31.Sep31TransactionStore import org.stellar.anchor.sep6.Sep6Transaction import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.sep6.Sep6TransactionUtils +import org.stellar.anchor.util.GsonUtils import org.stellar.anchor.util.StringHelper.json import org.stellar.sdk.KeyPair @@ -949,6 +950,36 @@ class ClientStatusCallbackHandlerTest { ) } + // WIRE-05: a response read from the published key reaches the callback, so the metadata cannot + // sit in a field the getter does not read + @Test + fun `fromSep31Txn should read the metadata published under required_info_updates_fields`() { + val txnResponse = + GsonUtils.getInstance() + .fromJson( + """ + { + "id": "sep31-id", "sep": "31", "kind": "receive", + "status": "pending_sender", + "required_info_updates": ["receiver_bank_account"], + "required_info_updates_fields": { + "receiver_bank_account": { + "description": "The receiver's bank account number", + "choices": ["SEPA", "SWIFT"] + } + } + } + """, + GetTransactionResponse::class.java, + ) + + val sep31Txn = ClientStatusCallbackHandler.fromSep31Txn(txnResponse) + + val field = sep31Txn.requiredInfoUpdates.transaction["receiver_bank_account"]!! + assertEquals("The receiver's bank account number", field.description) + assertEquals(listOf("SEPA", "SWIFT"), field.choices) + } + // The wallet server verifies the signature over the UTF-8 bytes of the body it receives. A JVM // whose default charset is not UTF-8 (java 17 with no LANG set, as in a bare container) used to // sign different bytes for any non-ASCII character. Gradle forces UTF-8 on the test JVM, so the