From 2bd18eb50df5ce723f9551f5b95cd4aba9c22704 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 05:10:54 +0000 Subject: [PATCH] test(clink): regression tests for the audit fixes Locks in the protocol-layer fixes that were previously only compile-checked: - offerLargePriceRoundTripIsUnsigned: a price > Int.MAX_VALUE round-trips as a positive Long (guards the unsigned-decode fix). - cannotDecryptAuthoredEventMissingRecipient: an authored event with no p tag can't be decrypted by anyone (guards the no-self-fallback conversationPeer). - manageCreateRequestSerializesNested + manageFailureResponseParsesField: the Manage request nests under offer.fields, payer_data is a string list, and the failure response carries field (guards the 21003 shape fix). All CLINK tests pass. --- .../experimental/clink/ClinkEventTest.kt | 49 +++++++++++++++++++ .../clink/pointers/ClinkPointerTest.kt | 13 +++++ 2 files changed, 62 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt index 39539aff19..8fb62cb979 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.quartz.experimental.clink import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse @@ -88,6 +92,23 @@ class ClinkEventTest { assertFalse(request.canDecrypt(stranger)) } + @Test + fun cannotDecryptAuthoredEventMissingRecipient() { + // A malformed event I authored but with no `p` tag: my own key must NOT be used as + // the conversation peer (no self-fallback), so neither party can derive a key. + val noRecipient = + OfferEvent( + id = "a".repeat(64), + pubKey = payer.pubKey, + createdAt = 1L, + tags = arrayOf(ClinkVersionTag.assemble()), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + assertFalse(noRecipient.canDecrypt(payer)) + assertFalse(noRecipient.canDecrypt(service)) + } + // --- JSON DTOs --- @Test @@ -136,4 +157,32 @@ class ClinkEventTest { assertTrue(parsed.isOk()) assertEquals("deadbeef", parsed.preimage) } + + @Test + fun manageCreateRequestSerializesNested() { + val request = + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_CREATE, + offer = ManageOffer(fields = OfferFields("Coffee", 1500L, "https://x/cb", listOf("email", "name"))), + ) + val json = OptimizedJsonMapper.toJson(request) + + // Offer data is nested under offer.fields (not flat). + assertTrue(json.contains("\"offer\""), json) + assertTrue(json.contains("\"fields\""), json) + + val parsed = OptimizedJsonMapper.fromJsonTo(json) + assertEquals("Coffee", parsed.offer?.fields?.label) + assertEquals(1500L, parsed.offer?.fields?.price_sats) + // payer_data is a list of field names, not a map. + assertEquals(listOf("email", "name"), parsed.offer?.fields?.payer_data) + } + + @Test + fun manageFailureResponseParsesField() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"res":"GFY","code":5,"error":"bad","field":"price_sats"}""") + assertFalse(parsed.isOk()) + assertEquals("price_sats", parsed.field) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt index 42d99c8b09..07c4971f36 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt @@ -52,6 +52,19 @@ class ClinkPointerTest { assertEquals(offer, parsed) } + @Test + fun offerLargePriceRoundTripIsUnsigned() { + // A price with the high bit set (> Int.MAX_VALUE) must round-trip as a positive + // Long — the price is an unsigned 4-byte big-endian integer, so reading it signed + // would wrap it negative. + val price = 3_000_000_000L + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, price) + val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer + + assertEquals(price, parsed.price) + assertEquals(offer, parsed) + } + @Test fun debitStaticRoundTrip() { val debit = NDebit(pubKey, listOf(relay), "pointer-7", null)