diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 6541bae0c8..b3fd2062b0 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -14,6 +14,7 @@ import org.skyscreamer.jsonassert.JSONCompareMode.LENIENT import org.skyscreamer.jsonassert.comparator.CustomComparator import org.springframework.data.domain.Sort.Direction import org.springframework.data.domain.Sort.Direction.DESC +import org.stellar.anchor.api.asset.AssetInfo import org.stellar.anchor.api.exception.SepException import org.stellar.anchor.api.exception.SepNotAuthorizedException import org.stellar.anchor.api.exception.SepNotFoundException @@ -354,7 +355,8 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { fun createTx( senderCustomer: Sep12PutCustomerResponse, - receiverCustomer: Sep12PutCustomerResponse + receiverCustomer: Sep12PutCustomerResponse, + fields: HashMap? = null ): Sep31PostTransactionResponse { // Create asset quote val quote = @@ -369,6 +371,7 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { txnRequest.senderId = senderCustomer.id txnRequest.receiverId = receiverCustomer.id txnRequest.quoteId = quote.id + if (fields != null) txnRequest.fields = Sep31TxnFields(fields) val postTxResponse = sep31Client.postTransaction(txnRequest) return postTxResponse } @@ -497,7 +500,11 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { // event/observer, racing this PATCH into the same OptimisticLockingFailureException // TransactionService throws as "Transaction was modified by another request. Please re-read // the transaction state and retry if appropriate." — so retry on that exact message. - private fun requestInfoUpdate(txId: String, vararg fieldNames: String) { + private fun requestInfoUpdate( + txId: String, + vararg fieldNames: String, + fieldMetadata: Map? = null + ) { val request = PatchTransactionsRequest.builder() .records( @@ -507,6 +514,7 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { .id(txId) .status(PENDING_TRANSACTION_INFO_UPDATE) .requiredInfoUpdates(fieldNames.toList()) + .requiredInfoUpdatesFields(fieldMetadata) .build() ) ) @@ -614,6 +622,132 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { } } + // The configured required fields with `receiver_account_number` padded so the serialized map has + // exactly `serializedLength` characters. + private fun fieldsOfSerializedLength(serializedLength: Int): HashMap { + val fields = + hashMapOf( + "receiver_routing_number" to "r0123", + "receiver_account_number" to "", + "type" to "SWIFT" + ) + val padding = serializedLength - GsonUtils.getInstance().toJson(fields).length + check(padding >= 0) { "$serializedLength is shorter than the required fields" } + fields["receiver_account_number"] = "a".repeat(padding) + assertEquals(serializedLength, GsonUtils.getInstance().toJson(fields).length) + return fields + } + + private fun postWithFields(fields: HashMap): Sep31PostTransactionResponse { + val (senderCustomer, receiverCustomer) = mkCustomers() + return createTx(senderCustomer, receiverCustomer, fields) + } + + @Test + fun `test POST sep31 transactions persists fields longer than 255 characters`() { + // The PoC of HackerOne #4113076: a 300-character value used to answer 500 with the INSERT text. + val fields = + hashMapOf( + "receiver_routing_number" to "r0123", + "receiver_account_number" to "a".repeat(300), + "type" to "SWIFT" + ) + + val posted = postWithFields(fields) + + assertFalse(posted.id.isNullOrBlank()) + assertEquals(PENDING_RECEIVER.status, platformApiClient.getTransaction(posted.id).status.status) + } + + @Test + fun `test POST sep31 transactions persists fields of exactly 255 characters`() { + val fields = fieldsOfSerializedLength(255) + + val posted = postWithFields(fields) + + assertFalse(posted.id.isNullOrBlank()) + assertEquals(fields, platformApiClient.getTransaction(posted.id).fields) + } + + @Test + fun `test POST sep31 transactions persists fields of exactly 256 characters`() { + val fields = fieldsOfSerializedLength(256) + + val posted = postWithFields(fields) + + assertFalse(posted.id.isNullOrBlank()) + assertEquals(fields, platformApiClient.getTransaction(posted.id).fields) + } + + @Test + fun `test long fields read back through the Platform API`() { + val fields = fieldsOfSerializedLength(600) + + val posted = postWithFields(fields) + + val stored = platformApiClient.getTransaction(posted.id).fields + assertEquals(fields.keys, stored.keys) + fields.forEach { (name, value) -> assertEquals(value, stored[name], name) } + } + + @Test + fun `test non-ASCII fields round trip`() { + val fields = fieldsOfSerializedLength(300) + fields["receiver_account_number"] = "José Ñandú 東京都 \uD83D\uDE80 ".repeat(20) + + val posted = postWithFields(fields) + + assertEquals(fields, platformApiClient.getTransaction(posted.id).fields) + } + + @Test + fun `test PATCH sep31 transactions merges fields past 255 characters`() { + val (senderCustomer, receiverCustomer) = mkCustomers() + val posted = createTx(senderCustomer, receiverCustomer) + requestInfoUpdate(posted.id, "receiver_account_number") + val longValue = "a".repeat(300) + + val patched = + sep31Client.patchTransaction( + posted.id, + Sep31PatchTransactionRequest.builder() + .fields(Sep31TxnFields(hashMapOf("receiver_account_number" to longValue))) + .build() + ) + + assertEquals(PENDING_RECEIVER.status, patched.transaction.status) + val stored = platformApiClient.getTransaction(posted.id).fields + assertEquals(longValue, stored["receiver_account_number"]) + // The merge keeps the fields the PATCH did not mention. + assertEquals("r0123", stored["receiver_routing_number"]) + assertEquals("SWIFT", stored["type"]) + } + + @Test + fun `test required_info_updates past 255 characters persist`() { + val (senderCustomer, receiverCustomer) = mkCustomers() + val posted = createTx(senderCustomer, receiverCustomer) + val requested = + (1..10).associate { + "receiver_bank_account_holder_name_$it" to + AssetInfo.Field.builder() + .description("Description $it of the field the receiving bank asks for again") + .choices(if (it % 2 == 0) listOf("first", "second") else null) + .optional(it % 3 == 0) + .build() + } + + requestInfoUpdate(posted.id, *requested.keys.toTypedArray(), fieldMetadata = requested) + + val stored = platformApiClient.getTransaction(posted.id).requiredInfoUpdatesFields + assertEquals(requested.keys, stored.keys) + requested.forEach { (name, expected) -> + assertEquals(expected.description, stored[name]!!.description, "$name description") + assertEquals(expected.choices, stored[name]!!.choices, "$name choices") + assertEquals(expected.isOptional, stored[name]!!.isOptional, "$name optional") + } + } + @Test fun testBadAsset() { val customer = diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java b/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java index 68325a6228..d9e89ecbff 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java @@ -4,10 +4,17 @@ import static org.stellar.anchor.util.Log.*; import io.sentry.SentryLevel; +import jakarta.persistence.PersistenceException; import jakarta.transaction.NotSupportedException; +import java.sql.SQLException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.transaction.TransactionException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; @@ -18,6 +25,8 @@ import org.stellar.anchor.platform.utils.RequestBodySizeLimitFilter; public abstract class AbstractControllerExceptionHandler { + static final String INTERNAL_ERROR_MESSAGE = "An internal error occurred."; + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler({SepValidationException.class, BadRequestException.class}) public SepExceptionResponse handleBadRequest(AnchorException ex) { @@ -104,6 +113,25 @@ SepExceptionResponse handleNotFound(Exception ex) { public SepExceptionResponse handleInternalError(Exception ex) { errorEx(ex); captureException(ex); + // A persistence failure's message carries the driver error and the SQL statement. The + // operator keeps it in the log and in Sentry; the caller gets a fixed text. + if (hasPersistenceCause(ex)) { + return new SepExceptionResponse(INTERNAL_ERROR_MESSAGE); + } return new SepExceptionResponse(ex.getMessage()); } + + private static boolean hasPersistenceCause(Throwable ex) { + // The visited set ends the walk on a cyclic cause chain, which `initCause` can build. + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable cause = ex; cause != null && visited.add(cause); cause = cause.getCause()) { + if (cause instanceof DataAccessException + || cause instanceof TransactionException + || cause instanceof PersistenceException + || cause instanceof SQLException) { + return true; + } + } + return false; + } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep31Transaction.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep31Transaction.java index 6337a8b176..527279f40d 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep31Transaction.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep31Transaction.java @@ -1,7 +1,6 @@ package org.stellar.anchor.platform.data; import com.google.gson.annotations.SerializedName; -import com.google.gson.reflect.TypeToken; import jakarta.persistence.*; import java.util.Map; import lombok.Getter; @@ -86,40 +85,16 @@ public String getProtocol() { @Column(name = "funding_method") String fundingMethod; - // Ignored by JPA and Gson @SerializedName("fields") - @Transient - Map fields; - - @Access(AccessType.PROPERTY) @Column(name = "fields") - public String getFieldsJson() { - return gson.toJson(this.fields); - } - - public void setFieldsJson(String fieldsJson) { - if (fieldsJson != null) { - this.fields = gson.fromJson(fieldsJson, new TypeToken>() {}.getType()); - } - } + @JdbcTypeCode(SqlTypes.JSON) + Map fields; - // Ignored by JPA and Gson @SerializedName("required_info_updates") - @Transient + @Column(name = "required_info_updates") + @JdbcTypeCode(SqlTypes.JSON) Sep31Info.Fields requiredInfoUpdates; - @Access(AccessType.PROPERTY) - @Column(name = "requiredInfoUpdates") - public String getRequiredInfoUpdatesJson() { - return gson.toJson(this.requiredInfoUpdates); - } - - public void setRequiredInfoUpdatesJson(String requiredInfoUpdatesJson) { - if (requiredInfoUpdatesJson != null) { - this.requiredInfoUpdates = gson.fromJson(requiredInfoUpdatesJson, Sep31Info.Fields.class); - } - } - Boolean refunded; @Column(columnDefinition = "json") diff --git a/platform/src/main/resources/db/migration/V37__sep31_json_columns.sql b/platform/src/main/resources/db/migration/V37__sep31_json_columns.sql new file mode 100644 index 0000000000..a85e08e6ca --- /dev/null +++ b/platform/src/main/resources/db/migration/V37__sep31_json_columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE sep31_transaction ALTER COLUMN fields TYPE JSON USING fields::json; +ALTER TABLE sep31_transaction ALTER COLUMN required_info_updates TYPE JSON USING required_info_updates::json; diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandlerTest.kt index 97735daa8f..9afe1dbcd6 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandlerTest.kt @@ -1,12 +1,38 @@ package org.stellar.anchor.platform.controller +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import io.sentry.Sentry +import io.sentry.protocol.SentryId +import jakarta.persistence.PersistenceException +import java.sql.SQLException +import java.time.Duration import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively import org.junit.jupiter.api.Test +import org.springframework.dao.DataIntegrityViolationException import org.springframework.http.HttpStatus +import org.springframework.transaction.CannotCreateTransactionException +import org.springframework.transaction.TransactionSystemException import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus +import org.stellar.anchor.api.exception.BadRequestException +import org.stellar.anchor.api.exception.SepCustomerInfoNeededException +import org.stellar.anchor.api.exception.SepNotAuthorizedException +import org.stellar.anchor.api.exception.SepNotFoundException +import org.stellar.anchor.api.exception.SepNotImplementedException import org.stellar.anchor.api.exception.SepRateLimitExceededException +import org.stellar.anchor.api.exception.ServerErrorException +import org.stellar.anchor.platform.controller.observer.ObserverControllerExceptionHandler +import org.stellar.anchor.platform.controller.platform.PlatformControllerExceptionHandler import org.stellar.anchor.platform.controller.sep.SepControllerExceptionHandler +import org.stellar.anchor.util.Log /** * Covers the SEP-10 "GET /auth rejects requests with no 'account' parameter" gap: `account` is a @@ -78,4 +104,248 @@ class AbstractControllerExceptionHandlerTest { assertEquals(HttpStatus.TOO_MANY_REQUESTS, responseStatus.value) } + + // The driver text of the #4113076 PoC: a 300-character field value overflowing VARCHAR(255). + private val pocMessage = + "could not execute statement [ERROR: value too long for type character varying(255)] " + + "[insert into sep31_transaction (amount_expected,fields,required_info_updates) " + + "values (?,?,?)]" + + private val fixedBody = "An internal error occurred." + + // One input per way the criteria name a persistence failure: each of the four types, and a + // wrapper that only has one in its cause chain. + private fun persistenceFailures(): List = + listOf( + DataIntegrityViolationException(pocMessage), + TransactionSystemException(pocMessage), + PersistenceException(pocMessage), + SQLException(pocMessage), + RuntimeException("outer $pocMessage", SQLException(pocMessage)), + ) + + @Test + fun `data access exception answers a fixed 500 body`() { + val response = + SepControllerExceptionHandler() + .handleInternalError(DataIntegrityViolationException(pocMessage)) + + assertEquals(fixedBody, response.error) + } + + @Test + fun `transaction exception answers a fixed 500 body`() { + val handler = SepControllerExceptionHandler() + + assertEquals( + fixedBody, + handler.handleInternalError(TransactionSystemException(pocMessage)).error + ) + assertEquals( + fixedBody, + handler.handleInternalError(CannotCreateTransactionException(pocMessage)).error + ) + } + + @Test + fun `persistence exception answers a fixed 500 body`() { + val response = + SepControllerExceptionHandler().handleInternalError(PersistenceException(pocMessage)) + + assertEquals(fixedBody, response.error) + } + + @Test + fun `sql exception answers a fixed 500 body`() { + val response = SepControllerExceptionHandler().handleInternalError(SQLException(pocMessage)) + + assertEquals(fixedBody, response.error) + } + + @Test + fun `wrapped persistence exception answers a fixed 500 body`() { + val wrapped = + RuntimeException( + "outer $pocMessage", + IllegalStateException("middle $pocMessage", SQLException(pocMessage)) + ) + + val response = SepControllerExceptionHandler().handleInternalError(wrapped) + + assertEquals(fixedBody, response.error) + } + + @Test + fun `sanitized body leaks nothing`() { + val response = + SepControllerExceptionHandler() + .handleInternalError(DataIntegrityViolationException(pocMessage)) + + for (leaked in + listOf( + pocMessage, + "insert into", + "sep31_transaction", + "required_info_updates", + "varchar(255)", + "value too long", + "character varying", + )) { + assertFalse( + response.error.contains(leaked, ignoreCase = true), + "the body must not contain \"$leaked\": ${response.error}" + ) + } + } + + @Test + fun `sanitized exception is logged and captured`() { + mockkStatic(Log::class) + mockkStatic(Sentry::class) + try { + every { Log.errorEx(any()) } just Runs + every { Sentry.captureException(any()) } returns SentryId.EMPTY_ID + + for (ex in persistenceFailures()) { + SepControllerExceptionHandler().handleInternalError(ex) + + verify(exactly = 1) { Log.errorEx(ex) } + verify(exactly = 1) { Sentry.captureException(ex) } + } + } finally { + unmockkStatic(Sentry::class) + unmockkStatic(Log::class) + } + } + + @Test + fun `other exception keeps its message`() { + val response = + SepControllerExceptionHandler().handleInternalError(IllegalStateException("not a db failure")) + + assertEquals("not a db failure", response.error) + } + + @Test + fun `internal error handler is annotated to produce HTTP 500`() { + val method = + AbstractControllerExceptionHandler::class + .java + .getMethod("handleInternalError", Exception::class.java) + + assertEquals( + HttpStatus.INTERNAL_SERVER_ERROR, + method.getAnnotation(ResponseStatus::class.java).value + ) + } + + @Test + fun `all three advices sanitize persistence errors`() { + val advices: List = + listOf( + SepControllerExceptionHandler(), + PlatformControllerExceptionHandler(), + ObserverControllerExceptionHandler(), + ) + + for (advice in advices) { + for (ex in persistenceFailures()) { + assertEquals( + fixedBody, + advice.handleInternalError(ex).error, + "${advice.javaClass.simpleName} / ${ex.javaClass.simpleName}" + ) + } + } + } + + @Test + fun `persistence failures are routed to the internal error handler`() { + // Spring picks the closest @ExceptionHandler for the thrown type. No other handler may claim a + // persistence failure, or the fixed body would never be reached for it. + val handlers = + AbstractControllerExceptionHandler::class.java.declaredMethods.filter { + it.isAnnotationPresent(ExceptionHandler::class.java) + } + + for (ex in persistenceFailures()) { + val claiming = + handlers + .filter { m -> + m.getAnnotation(ExceptionHandler::class.java).value.any { it.java.isInstance(ex) } + } + .map { it.name } + assertEquals(listOf("handleInternalError"), claiming, ex.javaClass.simpleName) + } + } + + @Test + fun `cyclic cause chain answers without looping`() { + val persistence = SQLException(pocMessage) + val wrapper = RuntimeException("outer $pocMessage") + persistence.initCause(wrapper) + wrapper.initCause(persistence) + + val other1 = IllegalStateException("not a db failure") + val other2 = IllegalArgumentException("also not a db failure") + other1.initCause(other2) + other2.initCause(other1) + + val handler = SepControllerExceptionHandler() + + assertTimeoutPreemptively(Duration.ofSeconds(5)) { + assertEquals(fixedBody, handler.handleInternalError(wrapper).error) + assertEquals("not a db failure", handler.handleInternalError(other1).error) + } + } + + @Test + fun `specific handlers are unchanged`() { + // Typed as the abstract class: `handleNotFound` is package-private there. + val handler: AbstractControllerExceptionHandler = SepControllerExceptionHandler() + + assertEquals("bad input", handler.handleBadRequest(BadRequestException("bad input")).error) + assertEquals("no token", handler.handleAuthError(SepNotAuthorizedException("no token")).error) + assertEquals("missing", handler.handleNotFound(SepNotFoundException("missing")).error) + assertEquals( + "not yet", + handler.handleNotImplementedError(SepNotImplementedException("not yet")).error + ) + assertEquals( + "upstream down", + handler.handleServerErrorException(ServerErrorException("upstream down")).error + ) + assertEquals( + listOf("first_name"), + handler.handle(SepCustomerInfoNeededException(listOf("first_name"))).fields + ) + assertEquals( + "The \"account\" parameter is missing.", + handler + .handleMissingParams(MissingServletRequestParameterException("account", "String")) + .error + ) + assertEquals( + "too many", + handler.handleRateLimitExceeded(SepRateLimitExceededException("too many")).error + ) + + // Every specific handler keeps the status it answers with. + val statuses = + mapOf( + "handleBadRequest" to HttpStatus.BAD_REQUEST, + "handleMissingParams" to HttpStatus.BAD_REQUEST, + "handleAuthError" to HttpStatus.FORBIDDEN, + "handle" to HttpStatus.FORBIDDEN, + "handleRateLimitExceeded" to HttpStatus.TOO_MANY_REQUESTS, + "handleNotImplementedError" to HttpStatus.NOT_IMPLEMENTED, + "handleServerErrorException" to HttpStatus.BAD_GATEWAY, + "handleNotFound" to HttpStatus.NOT_FOUND, + ) + for ((name, status) in statuses) { + val method = + AbstractControllerExceptionHandler::class.java.declaredMethods.single { it.name == name } + assertEquals(status, method.getAnnotation(ResponseStatus::class.java).value, name) + } + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/data/JdbcSep31TransactionTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/data/JdbcSep31TransactionTest.kt index f7635d9bab..64f7b9e49e 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/data/JdbcSep31TransactionTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/data/JdbcSep31TransactionTest.kt @@ -1,6 +1,126 @@ package org.stellar.anchor.platform.data +import com.fasterxml.jackson.databind.ObjectMapper +import java.lang.reflect.Field +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +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.asset.Sep31Info +import org.stellar.anchor.util.GsonUtils + +/** + * What these tests pin: the JSON mapping annotations on `fields` and `required_info_updates`, and + * that the declared field types survive a Jackson round trip (long, non-ASCII, empty, absent and + * values written by the old Gson code). What they do not prove is Hibernate itself: that a document + * and not a quoted string reaches the `json` column, and that an in-place edit of `fields` is + * dirty-checked. The repo has no database in its unit tests, so those are carried by the live + * `Sep31Tests` (long POST, PATCH merge, `required_info_updates` read-back) on PostgreSQL. + */ class JdbcSep31TransactionTest { + // Hibernate writes a @JdbcTypeCode(SqlTypes.JSON) attribute with Jackson, built like this. + private val jackson = ObjectMapper().findAndRegisterModules() + + private fun column(name: String): Field = JdbcSep31Transaction::class.java.getDeclaredField(name) + + /** Writes and reads a value the way the JSON column does, with the entity's declared type. */ + private fun roundTrip(attribute: String, value: T): T? { + val type = jackson.typeFactory.constructType(column(attribute).genericType) + val json = jackson.writeValueAsString(value) + return jackson.readValue(json, type) + } + + @Test + fun `fields and required_info_updates are mapped as json`() { + for ((attribute, columnName) in + mapOf("fields" to "fields", "requiredInfoUpdates" to "required_info_updates")) { + val field = column(attribute) + assertEquals( + SqlTypes.JSON, + field.getAnnotation(JdbcTypeCode::class.java)?.value, + "$attribute must use the JSON JDBC type" + ) + assertEquals( + columnName, + field.getAnnotation(jakarta.persistence.Column::class.java)?.name, + "$attribute must map to $columnName" + ) + assertNull( + field.getAnnotation(jakarta.persistence.Transient::class.java), + "$attribute must be persisted" + ) + } + val accessors = JdbcSep31Transaction::class.java.declaredMethods.map { it.name } + assertTrue( + accessors.none { it.endsWith("FieldsJson") || it.endsWith("RequiredInfoUpdatesJson") }, + "no string accessor may stand between the entity and the JSON column: $accessors" + ) + } + + @Test + fun `a 300-character field value survives a jackson round trip`() { + val fields = mapOf("receiver_account_number" to "a".repeat(300), "other" to "b") + + assertEquals(fields, roundTrip("fields", fields)) + } + + @Test + fun `non-ASCII field values survive a jackson round trip`() { + val fields = + mapOf( + "name" to "José Ñandú", + "city" to "東京都 \uD83D\uDE80", + "note" to "quote \" and \\ and \n" + ) + + assertEquals(fields, roundTrip("fields", fields)) + } + + @Test + fun `an empty map and an absent value survive a jackson round trip`() { + assertEquals(emptyMap(), roundTrip("fields", emptyMap())) + assertNull(roundTrip?>("fields", null)) + assertNull(roundTrip("requiredInfoUpdates", null)) + } + + @Test + fun `required_info_updates keep descriptions, choices and optional flags through a jackson round trip`() { + val updates = Sep31Info.Fields() + updates.transaction = + (1..10).associate { + "field_$it" to + AssetInfo.Field.builder() + .description("Description of the field number $it, long enough to pass 255 characters") + .choices(if (it % 2 == 0) listOf("a", "b") else null) + .optional(it % 3 == 0) + .build() + } + + assertEquals(updates, roundTrip("requiredInfoUpdates", updates)) + } + + @Test + fun `values written by the old code read into the same objects`() { + val gson = GsonUtils.getInstance() + val fields = mapOf("receiver_account_number" to "a9999") + val updates = Sep31Info.Fields() + updates.transaction = + mapOf("receiver_account_number" to AssetInfo.Field.builder().description("Account").build()) + + val fieldsType = jackson.typeFactory.constructType(column("fields").genericType) + val updatesType = jackson.typeFactory.constructType(column("requiredInfoUpdates").genericType) + + assertEquals(fields, jackson.readValue>(gson.toJson(fields), fieldsType)) + assertEquals(updates, jackson.readValue(gson.toJson(updates), updatesType)) + // The old code wrote the text `null` for an absent value. + assertNull( + jackson.readValue?>(gson.toJson(null as Map?), fieldsType) + ) + } + private val refundsJsonNoRefundPayment = """ { diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/data/Sep31JsonMigrationTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/data/Sep31JsonMigrationTest.kt new file mode 100644 index 0000000000..5ca690b117 --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/data/Sep31JsonMigrationTest.kt @@ -0,0 +1,57 @@ +package org.stellar.anchor.platform.data + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Static checks of `V37__sep31_json_columns.sql`. The repo has no database in its unit tests, so + * what the migration does to real rows (types, preserved values, the failure on invalid JSON) is + * checked once on a PostgreSQL and recorded in the feature's `validation.md`. + */ +class Sep31JsonMigrationTest { + private val statements: List by lazy { + val sql = + javaClass.getResourceAsStream("/db/migration/V37__sep31_json_columns.sql")?.use { + it.readBytes().toString(Charsets.UTF_8) + } + assertNotNull(sql, "V37__sep31_json_columns.sql must be on the classpath") + sql!!.split(";").map { it.trim().replace(Regex("\\s+"), " ") }.filter { it.isNotEmpty() } + } + + private val alterColumn = + Regex( + "ALTER TABLE sep31_transaction ALTER COLUMN (\\w+) TYPE JSON USING (\\w+)::json", + RegexOption.IGNORE_CASE + ) + + @Test + fun `migration converts both columns to json with a cast`() { + val converted = statements.map { alterColumn.matchEntire(it) } + + assertTrue(converted.all { it != null }, "every statement must be an ALTER ... TYPE JSON USING") + // The cast reads the column it replaces: `USING fields::json`, never another expression. + converted.forEach { assertEquals(it!!.groupValues[1], it.groupValues[2]) } + assertEquals( + setOf("fields", "required_info_updates"), + converted.map { it!!.groupValues[1].lowercase() }.toSet() + ) + } + + @Test + fun `migration touches only the two columns`() { + val columns = statements.map { alterColumn.matchEntire(it)?.groupValues?.get(1)?.lowercase() } + + assertEquals(listOf("fields", "required_info_updates"), columns.sortedBy { it }) + // Nothing but sep31_transaction is named, so no other table is altered. + statements.forEach { + assertTrue(it.contains("sep31_transaction", ignoreCase = true), "other table in: $it") + } + } + + @Test + fun `migration holds exactly two statements`() { + assertEquals(2, statements.size) + } +}