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.
This commit is contained in:
Claude
2026-06-10 05:10:54 +00:00
parent 488d459984
commit 2bd18eb50d
2 changed files with 62 additions and 0 deletions
@@ -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<ManageRequest>(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<ManageResponse>("""{"res":"GFY","code":5,"error":"bad","field":"price_sats"}""")
assertFalse(parsed.isOk())
assertEquals("price_sats", parsed.field)
}
}
@@ -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)