Merge branch 'mantra' into claude/marmot-group-reindex-events-96a0d0

# Conflicts:
#	composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt
#	composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt
This commit is contained in:
Kgothatso Ngako
2026-09-06 01:52:10 +02:00
79 changed files with 18631 additions and 600 deletions

View File

@@ -0,0 +1,141 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Who can open a gift wrap, and what becomes of everyone else's.
*
* NIP-59 encrypts a wrap under ECDH(ephemeralPriv, recipientPub), and
* [GiftWrapEvent.create] discards that ephemeral key before it returns. The
* recipient named in the `p` tag is therefore the only party who can ever unseal
* one -- the sender included. Reading a wrap that is not ours is not a decryption
* that might fail, it is one that cannot be attempted, and the code that tried
* anyway threw `IllegalStateException: Invalid Mac` out of Nip44 and took the
* enclosing Room transaction down with it, so the event was rolled back and
* re-fetched on every later sync.
*
* These run real secp256k1 rather than recorded fixtures on purpose: the property
* under test is about the key agreement itself, and a fixture would only prove the
* fixture still parses.
*/
class GiftWrapMessageTest {
private val us = KeyPair()
private val peer = KeyPair()
private val stranger = KeyPair()
/**
* A real wrap, sealed the way DatabaseChatRepository seals one and then mapped
* into the entity the way NostrEvent.toGiftWrapMessageWithReceiverPTag maps it.
*/
private fun wrap(
sender: KeyPair,
recipient: KeyPair,
): GiftWrapMessage {
val signer = NostrSignerSync(sender)
val seal = signer.signNormal<SealedRumorEvent>(
createdAt = SEALED_AT,
kind = SealedRumorEvent.KIND,
tags = emptyArray(),
content = signer.nip44Encrypt(
plaintext = """{"kind":14,"content":"dumela"}""",
toPublicKey = recipient.pubKey.toHexKey(),
),
)
val giftWrap = GiftWrapEvent.create(
event = seal,
recipientPubKey = recipient.pubKey.toHexKey(),
createdAt = WRAPPED_AT,
)
return GiftWrapMessage(
id = giftWrap.id,
publicKey = giftWrap.pubKey,
receiverPublicKey = recipient.pubKey.toHexKey(),
receiverRelayHit = null,
content = giftWrap.content,
signature = giftWrap.sig,
nostrEventId = giftWrap.id,
createdAt = Instant.fromEpochSeconds(giftWrap.createdAt),
)
}
@Test
fun `a wrap addressed to us gives up its seal`() = runTest {
val seal = wrap(sender = peer, recipient = us).decryptGiftWrapSeal(us)
assertNotNull(seal)
// Only the wrap was anonymous. The seal inside carries the real sender, which
// is what lets the impersonation check downstream compare it to the payload.
assertEquals(peer.pubKey.toHexKey(), seal.publicKey)
}
@Test
fun `someone else's mail comes back null rather than throwing`() = runTest {
// The event from the crash report: a wrap between two other people, pulled in
// by a filter that named a peer where it should have named us.
val theirs = wrap(sender = stranger, recipient = peer)
assertNull(theirs.decryptGiftWrapSeal(us))
}
@Test
fun `not even the sender can reopen what they sent`() = runTest {
// What the old branch called "a message we may have sent" and tried to decrypt
// regardless. The key that encrypted it no longer exists anywhere; holding the
// sending identity buys nothing back.
val ours = wrap(sender = us, recipient = peer)
assertNull(ours.decryptGiftWrapSeal(us))
}
@Test
fun `isAddressedTo reads the p tag whatever case it arrived in`() {
val message = wrap(sender = peer, recipient = us)
assertTrue(message.isAddressedTo(us))
assertFalse(message.isAddressedTo(peer))
assertTrue(
message
.copy(receiverPublicKey = message.receiverPublicKey.uppercase())
.isAddressedTo(us),
)
}
@Test
fun `isAddressedTo answers exactly what unsealing would`() = runTest {
// NostrDao skips indexing on isAddressedTo and throws GiftWrapUnsealException on
// a null seal. Should those two ever disagree, one path or the other is wrong:
// either mail we can open is skipped, or the transaction rolls back again.
listOf(
wrap(sender = peer, recipient = us),
wrap(sender = stranger, recipient = peer),
wrap(sender = us, recipient = peer),
).forEach { message ->
assertEquals(
message.isAddressedTo(us),
message.decryptGiftWrapSeal(us) != null,
"isAddressedTo and decryptGiftWrapSeal disagree on ${message.id}",
)
}
}
private companion object {
const val SEALED_AT = 1_700_000_000L
const val WRAPPED_AT = 1_700_000_100L
}
}

View File

@@ -0,0 +1,144 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
/**
* The first version of an artifact is derived, not delivered.
*
* The group signs an artifact and nothing else, so the version it starts life
* with is not an event anybody sent: every device builds the row for itself out
* of the artifact it already holds. That only works while every device builds
* the *same* row, and nothing about the ids would show it if they stopped —
* they are content hashes, opaque hex either way. What would show is a group
* that quietly disagrees about which version a chapter hangs off, with the
* artifact looking identical on every screen.
*/
class InitialArtifactVersionTest {
private val groupKey = "a".repeat(64)
private val dialectId = "b".repeat(64)
private val chatRoomId = "room"
private fun signedArtifact(
name: String = "In Detention",
versionLabel: String = "1.0",
createdAt: Long = 1_700_000_000,
): ArtifactEvent {
val template = ArtifactEvent.build(
name = name,
url = "example.com",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = versionLabel,
createdAt = createdAt,
)
// Hashed rather than made up, so two fixtures that differ are two
// different artifacts here for the same reason they would be in the app.
return ArtifactEvent(
id = EventHasher.hashId(
pubKey = groupKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = groupKey,
createdAt = template.createdAt,
tags = template.tags,
content = template.content,
sig = "d".repeat(128)
)
}
@Test
fun `the derived version is a function of the artifact and nothing else`() {
// Every input has to come off the artifact. Reading the clock here would
// still agree with itself twice in a row -- and disagree between two
// devices that applied the same artifact minutes apart, which is the
// case nobody can reproduce on demand. So the timestamp is checked
// against the artifact's rather than against a second derivation.
val artifact = signedArtifact(createdAt = 1_700_000_000)
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
assertNotNull(version)
assertEquals(1_700_000_000, version.createdAt.epochSeconds)
}
@Test
fun `two devices derive the same first version from the same artifact`() {
val artifact = signedArtifact()
val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
assertNotNull(mine)
assertEquals(mine.id, theirs?.id)
assertEquals(mine.createdAt, theirs?.createdAt)
}
@Test
fun `an artifact signed at a different moment derives a different version`() {
// The artifact's own timestamp is bound into the derived id, so two
// proposals identical but for when they were made stay two artifacts
// with two first versions rather than colliding on one row.
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId)
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId)
assertNotNull(first)
assertNotNull(second)
assertNotEquals(first.id, second.id)
}
@Test
fun `the derived version hangs off the artifact and carries what it declared`() {
val artifact = signedArtifact(versionLabel = "First Edition")
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
assertEquals(artifact.id, version?.artifactId)
assertEquals("First Edition", version?.versionLabel)
// Authored by whoever authored the artifact -- the group, once signed --
// and unsigned, because nobody signed this.
assertEquals(groupKey, version?.publicKey)
assertEquals("", version?.signature)
}
@Test
fun `the label is bound into the id rather than hung beside it`() {
// Two artifacts alike but for the label must not derive one version
// between them: the id has to come from the whole event, or a group
// renaming a version would leave the row it replaces in place.
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId)
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId)
assertNotNull(first)
assertNotNull(second)
assertNotEquals(first.id, second.id)
}
@Test
fun `an artifact that declares no version derives none`() {
// Artifacts written before the artifact carried its first version.
val declared = signedArtifact()
val silent = ArtifactEvent(
id = declared.id,
pubKey = declared.pubKey,
createdAt = declared.createdAt,
tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
.toTypedArray(),
content = declared.content,
sig = declared.sig
)
assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId))
}
}

View File

@@ -0,0 +1,134 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.time.Instant
/**
* Where a commit's bytes land when the row that records it is written.
*
* `MarmotCommitResult` carries quartz's `CommitResult` payload fields verbatim --
* `commitBytes`, `welcomeBytes`, `groupInfoBytes`, `framedCommitBytes`,
* `preCommitExporterSecret`, the same names and all of them `ByteArray`. A value
* taken from the wrong field of the right object therefore typechecks, and
* `framedCommitBytes = commitResult.preCommitExporterSecret` reached the database
* that way and sat there unnoticed: the column documented to hold a broadcastable
* `MlsMessage(PublicMessage(FramedContent(commit)))` envelope held 32 bytes of the
* group's pre-commit exporter secret instead.
*
* Nothing caught it because nothing read the column. The bytes that reached the
* relay come off the in-memory `CommitResult`, so the wire stayed correct while the
* record of it did not, and the row is written precisely so that the
* acknowledgement path in `DatabaseNostrRepository` can pick work back up later. A
* rebroadcast reading `framedCommitBytes` would have published noise the group
* decrypts, fails to parse, and drops -- silent, which is this subsystem's
* characteristic failure.
*
* So the routing is pinned here. Every payload gets a distinct, self-identifying
* value: a field that ends up in the wrong column names both halves of the mistake
* when it fails, rather than comparing equal by accident.
*/
class MarmotCommitResultMappingTest {
private val commitBytes = "raw-commit".encodeToByteArray()
private val framedCommitBytes = "framed-commit-envelope".encodeToByteArray()
private val welcomeBytes = "welcome".encodeToByteArray()
private val groupInfoBytes = "group-info".encodeToByteArray()
/** Stands in for `MLS-Exporter("marmot", "group-event", 32)` at the pre-commit epoch. */
private val preCommitExporterSecret = ByteArray(32) { 0x5E }
private val commitEventId = "a".repeat(64)
private val chatRoomId = "b".repeat(64)
private val userPublicKey = "c".repeat(64)
private val peerKeyPackageEventId = "d".repeat(64)
private val createdAt = Instant.fromEpochSeconds(1_700_000_000)
private fun commitResult(
framedCommitBytes: ByteArray = this.framedCommitBytes,
preCommitExporterSecret: ByteArray = this.preCommitExporterSecret,
) = CommitResult(
commitBytes = commitBytes,
welcomeBytes = welcomeBytes,
groupInfoBytes = groupInfoBytes,
framedCommitBytes = framedCommitBytes,
preCommitExporterSecret = preCommitExporterSecret,
)
private fun map(commitResult: CommitResult) = MarmotCommitResult.from(
commitEventId = commitEventId,
commitResult = commitResult,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
peerKeyPackageEventId = peerKeyPackageEventId,
isOneMemberInitialGroupCreation = false,
createdAt = createdAt,
)
@Test
fun `every payload field lands in its own column`() {
val row = map(commitResult())
assertContentEquals(commitBytes, row.commitBytes, "commitBytes")
assertContentEquals(welcomeBytes, row.welcomeBytes, "welcomeBytes")
assertContentEquals(groupInfoBytes, row.groupInfoBytes, "groupInfoBytes")
assertContentEquals(framedCommitBytes, row.framedCommitBytes, "framedCommitBytes")
assertContentEquals(
preCommitExporterSecret,
row.preCommitExporterSecret,
"preCommitExporterSecret"
)
}
@Test
fun `the framed commit column never holds the exporter secret`() {
// The regression. Stated as the invariant rather than as an equality check,
// so it keeps holding for a CommitResult this test did not anticipate.
val row = map(commitResult())
assertFalse(
row.framedCommitBytes.contentEquals(row.preCommitExporterSecret),
"the group's exporter secret was stored as the framed commit"
)
}
@Test
fun `a CommitResult that never framed its commit still stores a commit`() {
// quartz defaults framedCommitBytes to commitBytes, and the entity repeats that
// default. Whichever of the two a row ends up with, it must be a commit -- the
// fallback must not quietly become the secret either.
val unframed = CommitResult(
commitBytes = commitBytes,
welcomeBytes = welcomeBytes,
groupInfoBytes = groupInfoBytes,
preCommitExporterSecret = preCommitExporterSecret,
)
val row = map(unframed)
assertContentEquals(commitBytes, row.framedCommitBytes)
assertFalse(
row.framedCommitBytes.contentEquals(row.preCommitExporterSecret),
"the group's exporter secret was stored as the framed commit"
)
}
@Test
fun `the bookkeeping the acknowledgement path reads is carried through`() {
// DatabaseNostrRepository finds this row by the commit event's id and delivers the
// welcome using chatRoomId, userPublicKey and peerKeyPackageEventId. All four are
// supplied by the caller rather than the CommitResult, so they are checked here to
// keep the argument order of `from` honest -- every one of them is a 64-char hex
// string, and swapping two would otherwise typecheck as silently as the bug did.
val row = map(commitResult())
assertEquals(commitEventId, row.id)
assertEquals(chatRoomId, row.chatRoomId)
assertEquals(userPublicKey, row.userPublicKey)
assertEquals(peerKeyPackageEventId, row.peerKeyPackageEventId)
assertEquals(createdAt, row.createdAt)
assertFalse(row.isOneMemberInitialGroupCreation)
}
}

View File

@@ -0,0 +1,175 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.test.Test
import kotlin.test.assertEquals
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
/**
* The row on disk and the payload on the wire have to be the same event.
*
* `MantraDao` writes an entity whose id comes from `MantraX.fromXEventTemplate`,
* and separately builds the rumor it submits with `rumorOf`, which hashes the
* template itself. Both are supposed to produce one id. Nothing checks that they
* do, and nothing would notice if they stopped:
*
* - the submission would carry a `payloadId` naming an event nobody has,
* - `MarmotInnerEvent.payloadEventId` would stop matching the row it carries,
* so `deleteByPayloadEventId` would silently un-queue nothing and superseded
* translations would go out anyway,
* - and every receiver would create a *second* row rather than converging on
* the sender's, because entity ids are content hashes and the two sides would
* be hashing different things.
*
* All of that is silent. The ids are opaque hex either way.
*/
class RumorIdAgreementTest {
private val author = "a".repeat(64)
private val chatRoomId = "room"
private val other = "b".repeat(64)
/** Exactly what `MantraDao.rumorOf` does, and it must stay exactly that. */
private fun rumorIdOf(template: EventTemplate<*>): String = EventHasher.hashId(
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
)
@Test
fun `a dialect's row and its rumor agree`() {
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact's row and its rumor agree`() {
val template = ArtifactEvent.build(
name = "In Detention",
url = "example.com",
visibility = "private",
license = "cc",
dialectId = other,
versionLabel = "1.0"
)
val entity = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact version's row and its rumor agree`() {
val template = ArtifactVersionEvent.build(content = "1.0") {
addUnique(ArtifactIdTag.assemble(other))
}
val entity = MantraArtifactVersion.fromArtifactVersionEventTemplate(
artifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `a chapter's and a chunk's rows agree with their rumors`() {
val chapter = ChapterEvent.build(
artifactVersionId = other,
name = "Chapter 1",
originalText = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
val chunk = ChunkEvent.build(
chapterId = other,
text = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
assertEquals(
rumorIdOf(chapter),
MantraChapter.fromChapterEventTemplate(chapter, chatRoomId, author)?.id
)
assertEquals(
rumorIdOf(chunk),
MantraChunk.fromChunkEventTemplate(chunk, chatRoomId, author)?.id
)
}
@Test
fun `a translation version's row and its rumor agree`() {
val template = TranslationArtifactVersionEvent.build(
artifactVersionId = other,
dialectId = other,
name = "Sesotho",
visibility = "private",
license = "cc"
)
val entity = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
translationArtifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `the submission names the id the row was written under`() {
// The end of the chain the rest of this file checks a link of: what a
// receiver reads out of the envelope has to be the id the sender stored.
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(template, chatRoomId, author)
val payload = Event(
id = rumorIdOf(template),
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
val submission = SubmissionEvent.build(payload = payload)
val readBack = SubmissionEvent(
id = "f".repeat(64),
pubKey = author,
createdAt = submission.createdAt,
tags = submission.tags,
content = submission.content,
sig = ""
)
assertEquals(entity?.id, readBack.payloadId())
assertEquals(entity?.id, readBack.payload()?.id)
assertEquals(DialectEvent.KIND, readBack.payloadKind())
}
}

View File

@@ -0,0 +1,131 @@
package press.mantra.compose.database.model
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* When a request line in a transcript stops asking for something.
*
* The transcript renders a request with a tint and a "Review" affordance, and
* that is a promise: tapping it leads to a decision still there to be made.
* Keeping the promise means knowing when the decision has gone, and the rows are
* all there is to know it from -- a line rendered days later has no session to
* ask, and the room may have signed several things since.
*
* Two ways for a request to be over, and they are not the same. Answering it
* leaves a line of the reader's own and earns the tick. A session ending
* underneath it leaves nothing of theirs at all: a member who declined published
* nothing, and a quorum that signed without them wanted nothing. Both must drop
* the summons; neither may claim the member signed.
*/
class TranscriptRequestStateTest {
private val user = "u".repeat(64)
private val other = "o".repeat(64)
private var lastId = 0L
/** One transcript row, with only the four fields either rule reads. */
private fun line(
type: String,
at: Long,
sender: String = user
) = ChatMessage(
id = ++lastId,
senderPublicKey = sender,
isUserMessage = sender == user,
giftWrapPayloadId = null,
marmotGroupEventId = null,
marmotInnerEventId = null,
chatRoomId = "room",
content = "",
messageType = type,
createdAt = Instant.fromEpochSeconds(at)
)
@Test
fun `a signing request nobody has acted on is still asking`() {
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(line(ChatMessage.TYPE_FROST_STARTED, at = 9, sender = other), request)
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
}
@Test
fun `publishing the nonce answers the request that asked for it`() {
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11))
assertEquals(setOf(request.id), ChatMessage.answeredRequests(transcript))
}
@Test
fun `somebody else's nonce answers nothing`() {
// The fulfilment has to be this device's own: a transcript is full of other
// members taking the step this reader has yet to take.
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11, sender = other))
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
}
@Test
fun `declining settles the request without claiming it was signed`() {
// Declining publishes nothing, so there is no fulfilment to find. The
// failure the refusal writes is the only trace, and it has to be enough --
// otherwise the line goes on offering a decision already made.
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_FAILED, at = 11))
assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript))
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
}
@Test
fun `a group that signs without this member settles their request`() {
// A t-of-n key does not need everybody. Nothing of this member's is in the
// signature and nothing of theirs was ever published, so answered stays
// empty -- but there is no longer anything for them to decide.
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_COMPLETE, at = 12, sender = other))
assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript))
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
}
@Test
fun `an earlier session's ending does not close a later request`() {
// Rooms sign more than once, and the previous session's last line sits
// above this one's first.
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 20)
val transcript = listOf(line(ChatMessage.TYPE_FROST_COMPLETE, at = 9, sender = other), request)
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
}
@Test
fun `a ceremony step is settled by nothing`() {
// Signing is the one thing a member can refuse, so it is the only place a
// request can be over without them having answered it. A ceremony step is
// either taken or still waited on, and reading either ending as the end of
// one would drop a summons the ritual is still stalled on.
val request = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 10)
val transcript = listOf(
request,
line(ChatMessage.TYPE_FROST_FAILED, at = 11),
line(ChatMessage.TYPE_DKG_FAILED, at = 12, sender = other)
)
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
}
@Test
fun `each ceremony step is answered only by its own`() {
val hostKey = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY, at = 10)
val roundOne = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 12)
val transcript = listOf(hostKey, line(ChatMessage.TYPE_DKG_HOST_KEY, at = 11), roundOne)
assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript))
}
}

View File

@@ -1,5 +1,6 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -17,8 +18,10 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.FrostSigningEvents
@@ -217,6 +220,39 @@ class FrostSigningSessionTest {
signerIds = signerIds
)
@Test
fun `a session waits on its owner until they answer`() {
val open = session(signerId = 2, signerIds = null)
assertTrue(FrostSigningManager.isAwaitingApproval(open))
assertFalse(
FrostSigningManager.isAwaitingApproval(
open.copy(signApprovedAt = Instant.fromEpochSeconds(1))
)
)
}
@Test
fun `a session that has settled asks its owner nothing`() {
val open = session(signerId = 2, signerIds = null)
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE)))
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED)))
}
@Test
fun `a signature the group already made asks its owner nothing either`() {
// A t-of-n key does not need everybody, so a quorum can finish while one
// member's phone is still in a pocket. The session stays at its opening
// stage on their device until it next advances, and offering them the
// decision in that window offers two bad answers: a nonce nobody is
// waiting for, or a refusal that abandons a signature that exists.
val signedWithoutThem = session(signerId = 2, signerIds = "0,1")
.copy(signature = "a".repeat(128))
assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem))
}
@Test
fun `a member left out of the signer set is not a signer`() {
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
@@ -276,3 +312,175 @@ class FrostSigningSessionTest {
)
}
}
/**
* What a device needs on its row to finish a session it never took part in.
*
* `FrostSigningManager.advance` completes on an arrived signature ahead of the
* approval gate, and that hoist rests on one claim: closing a session needs
* nothing secret and nothing the member would have had to publish. Were it
* false -- were the aggregated nonce, the signer set or a share needed to check
* the result -- the gate would have to stay where it was, and a member the
* quorum did not need would be stuck being asked to sign something already
* signed.
*
* So the claim is spelled out here against a real 2-of-3 signature, from the
* row of the member who was left out of it.
*/
class FrostSigningCompletionTest {
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("2decade0000000000000000000000000000000000000000000000000000000b2")
),
nParticipants = 3,
threshold = 2
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/** The group's nostr identity, exactly as `unsignedEventOf` derives it. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
/** The event the group is asked to sign, built the way the manager builds it. */
private val unsignedEvent = Event(
id = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = "a dialect the group agreed on"
),
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = "a dialect the group agreed on",
sig = ""
)
/** A real signature from members 0 and 1. Member 2 is not in it and never was. */
private val signature: String = run {
val message = ByteVector(unsignedEvent.id.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("c".repeat(63) + "${signerId + 1}"),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val session = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = 3,
threshold = 2,
tweakCache = tweakCache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
session.aggregateSigs(partials).right!!.toHex()
}
/**
* Member 2's row, as it stands when the signature reaches them: they never
* approved, so nothing of theirs was ever published, and the coordinator
* never named them. Every column the completion path reads is here; the ones
* it must not need are deliberately left null.
*/
private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
dkgSessionId = "k".repeat(64),
threshold = 2,
participantCount = 3,
signerId = 2,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64),
aggregatedNonce = null,
signerIds = null,
signature = signature,
signApprovedAt = null
)
@Test
fun `a member who never took part can still check what the group signed`() {
val session = leftOutMemberSession(signature)
val signed = FrostSigningManager.signedEvent(session)!!
assertTrue(
Nip01Crypto.verify(
signature = signed.sig.hexToByteArray(),
hash = session.eventId.hexToByteArray(),
pubKey = signed.pubKey.hexToByteArray()
),
"completing must need only the row: the event, its id and the signature"
)
}
@Test
fun `the finished event is the one that was proposed, with a signature on it`() {
// Not rebuilt and not rehashed: the id a session is pinned to is the id
// the signature is over, so anything that changed here would produce an
// event whose signature verifies against nothing.
val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!!
assertEquals(unsignedEvent.id, signed.id)
assertEquals(unsignedEvent.pubKey, signed.pubKey)
assertEquals(unsignedEvent.createdAt, signed.createdAt)
assertEquals(unsignedEvent.kind, signed.kind)
assertEquals(unsignedEvent.content, signed.content)
assertEquals(signature, signed.sig)
}
@Test
fun `there is no finished event until the signature arrives`() {
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession()))
}
@Test
fun `the arrived signature is what stops the session asking`() {
// The pair that matters to the screen and the transcript: the same row,
// before and after the group finished without this member.
assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession()))
assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature)))
}
@Test
fun `a signature over a different event is refused`() {
// What the check is for. A coordinator passing off something else must not
// get it applied and announced as the group's, and the row is all there is
// to catch it with.
val other = leftOutMemberSession(signature).copy(
eventId = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = "something else entirely"
)
)
val signed = FrostSigningManager.signedEvent(other)!!
assertFalse(
Nip01Crypto.verify(
signature = signed.sig.hexToByteArray(),
hash = other.eventId.hexToByteArray(),
pubKey = signed.pubKey.hexToByteArray()
)
)
}
}

View File

@@ -0,0 +1,245 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* What a room's key-state announcement is allowed to convince a member of.
*
* The announcement is made by the coordinator, and the coordinator is untrusted
* by construction -- the same assumption every other part of the ceremony is
* written under. So the interesting cases here are all the ones where a state
* is *wrong*: a member who acts on a state naming a key their room was not made
* from signs with a share that cannot aggregate, or worse, treats a key the
* group does not hold as the key the group holds.
*
* `GroupKeyStateManager` needs a database and so cannot be stood up here. What
* can be is the check it defers to, which is where the whole trust model lives.
*/
class GroupKeyStateTest {
/** Stands in for a ceremony's output. Any valid point will do. */
private val thresholdPublicKey = PrivateKey(
Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
).publicKey().value.toHex()
/** A second group's key, for the states that name the wrong one. */
private val otherKey = PrivateKey(
Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2")
).publicKey().value.toHex()
private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
private val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
private fun state(
chatRoomId: String = this.chatRoomId,
thresholdPublicKey: String = this.thresholdPublicKey,
derivationPath: String = SharedKeyDerivation.formatPath(path)
) = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
thresholdPublicKey = thresholdPublicKey,
derivationPath = derivationPath,
announcedBy = "c00rd1na70r",
announcedAt = Instant.fromEpochSeconds(1_700_000_000)
)
@Test
fun `a state describing the room it was announced in verifies`() {
assertTrue(state().verifies())
}
@Test
fun `a state naming another group's key does not verify`() {
// The attack this is here for: a coordinator pointing the room at a key
// the group never made, so that everything signed in it is signed by
// whoever holds that key instead.
assertFalse(state(thresholdPublicKey = otherKey).verifies())
}
@Test
fun `a state naming the right key at the wrong path does not verify`() {
// The path is half the derivation, so getting it wrong reaches a
// different room just as surely as getting the key wrong does.
assertFalse(state(derivationPath = "m/9420/0/1").verifies())
}
@Test
fun `a state for one room does not verify against another`() {
assertFalse(state(chatRoomId = otherKey).verifies())
}
@Test
fun `a state carrying an unwalkable path does not verify`() {
// Hardened derivation needs the parent private key, which nobody in a
// threshold group has, so a hardened path was never walked to anything.
assertFalse(state(derivationPath = "m/9420'/0/0").verifies())
assertFalse(state(derivationPath = "9420/0/0").verifies())
assertFalse(state(derivationPath = "").verifies())
}
@Test
fun `the tags a state is announced on read back as they were written`() {
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path
)
assertEquals(chatRoomId, GroupKeyStateEvent.parseChatRoomId(tags))
assertEquals("ceremony-1", GroupKeyStateEvent.parseDkgSessionId(tags))
assertEquals(path, GroupKeyStateEvent.parsePath(tags))
}
@Test
fun `a threshold key is only read out of content that is one`() {
assertEquals(thresholdPublicKey, GroupKeyStateEvent.parseThresholdPublicKey(thresholdPublicKey))
// 32 bytes is an x-only key, not the 33-byte compressed point a ceremony
// reports; anything else is not a key at all.
assertNull(GroupKeyStateEvent.parseThresholdPublicKey(chatRoomId))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey(""))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey("not a key"))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey("z".repeat(66)))
}
@Test
fun `a path survives the trip through a tag`() {
val deep = listOf(9420L, 7L, 0L, 1L)
val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", deep)
assertEquals(deep, GroupKeyStateEvent.parsePath(tags))
}
@Test
fun `a room derived at a path other than the default still verifies at it`() {
// The reason the path is announced rather than assumed: a lookup that
// hardcodes MARMOT_ADMIN_GROUP_PATH cannot find this room at all.
val sibling = listOf(9420L, 0L, 1L)
val siblingRoom = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, sibling)
assertTrue(
state(
chatRoomId = siblingRoom,
derivationPath = SharedKeyDerivation.formatPath(sibling)
).verifies()
)
}
// ---- The announcement as it actually arrives -------------------------
//
// Everything above checks the verdict on a state already assembled. These
// check the assembling: a real Event, with the tags and content an
// announcement is carried on, through the function the inbound path calls.
private fun announcement(
content: String = thresholdPublicKey,
tags: Array<Array<String>> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path),
pubKey: String = "c00rd1na70r",
createdAt: Long = 1_700_000_000
) = Event(
id = "an-id",
pubKey = pubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content,
sig = ""
)
@Test
fun `an announcement of the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement())
assertEquals(chatRoomId, state?.chatRoomId)
assertEquals("ceremony-1", state?.dkgSessionId)
assertEquals(thresholdPublicKey, state?.thresholdPublicKey)
assertEquals("m/9420/0/0", state?.derivationPath)
// Attribution and ordering come off the event, not off the clock.
assertEquals("c00rd1na70r", state?.announcedBy)
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt)
}
@Test
fun `an announcement naming another group's key is dropped`() {
// The one that matters: a coordinator pointing the room at a key the
// group never made. Everything else here is malformed input; this is
// well-formed input that lies.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = otherKey)))
}
@Test
fun `an announcement addressed to another room is dropped`() {
val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere)))
}
@Test
fun `an announcement missing any of what it has to say is dropped`() {
val full = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
// No ceremony to reach a share through.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray())
)
)
// No path, so nothing to rebuild a TweakCache from.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray())
)
)
// No key.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = "")))
}
@Test
fun `an announcement carrying no d tag is judged on its derivation alone`() {
// The d tag is a convenience for a reader holding the event on its own.
// Dropping it loses nothing that matters, because the room it arrived in
// plus the derivation still settle the question.
val undirected = arrayOf(
arrayOf("frost_key", "ceremony-1"),
arrayOf("frost_path", "m/9420/0/0")
)
assertEquals(
chatRoomId,
GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId
)
}
@Test
fun `a path index wider than a uint32 is not a path`() {
// tweakScalar serialises an index as its low four bytes, so m/4294967296
// would otherwise walk exactly where m/0 does -- one room, two spellings,
// both verifying. Rejected at the parser so formatPath stays a round trip.
assertNull(SharedKeyDerivation.parsePathString("m/4294967296/0/0"))
assertNull(SharedKeyDerivation.parsePathString("m/-1/0/0"))
assertEquals(listOf(4294967295L), SharedKeyDerivation.parsePathString("m/4294967295"))
assertEquals(listOf(0L), SharedKeyDerivation.parsePathString("m/0"))
}
@Test
fun `a state whose path indices are out of range does not verify`() {
// Reachable only by constructing the row directly; the parser above
// refuses to build one. Checked because verifies() is what everything
// else defers to, and it should not be the thing that trusts its input.
assertFalse(state(derivationPath = "m/4294967296/0/0").verifies())
}
}

View File

@@ -0,0 +1,201 @@
package press.mantra.compose.managers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlinx.coroutines.runBlocking
/**
* The decision behind keeping an MLS group alive between messages.
*
* This cache exists because quartz drops a secret tree's skipped-generation keys
* on save, so rebuilding a group between two messages loses any message that
* arrives late -- permanently, and silently. See `docs/mls-skipped-keys.md`.
*
* Every one of these failures is invisible at runtime. Reuse too eagerly and a
* group carries on from a ratchet another writer has already moved, which
* corrupts decryption rather than failing it. Reuse too rarely and the cache
* does nothing at all, and the bug it was written for comes straight back with
* no symptom to notice. So the rule is asserted rather than reasoned about.
*/
class LiveInstanceCacheTest {
/** Stands in for an MlsGroup: mutable, and its persisted form is its content. */
private class Group(var state: String) {
/** How many times this particular instance was handed to a caller. */
var uses: Int = 0
}
private fun cache() = LiveInstanceCache<Group> { it.state }
@Test
fun `reuses the instance while nothing else has written`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
// The same object, not merely an equal one: what has to survive is the
// in-memory skipped-key map, which no amount of rebuilding recovers.
assertSame(first, second)
assertEquals(1, built)
assertEquals(2, second?.uses)
}
@Test
fun `rebuilds when something else wrote the stored state`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Sending a message advances the sender ratchet and saves; adding a
// member does too. Carrying on from an instance that has been overtaken
// would diverge the ratchet, which is worse than not caching at all.
stored = "written by someone else"
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("written by someone else") },
save = { stored = it },
block = { it }
)
assertEquals(2, built)
assertEquals(false, first === second)
}
@Test
fun `persists whatever the block left behind`() = runBlocking {
val cache = cache()
var stored: String? = "start"
cache.withInstance(
key = "room",
storedState = stored,
build = { Group("start") },
save = { stored = it },
block = { it.state = "advanced" }
)
assertEquals("advanced", stored)
}
@Test
fun `the state it records is the one it compares against next time`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
repeat(3) {
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
// Every use moves the instance on, as decrypting a message does.
block = { group -> group.state = "advanced ${group.uses++}" }
)
}
// Recording the pre-block state instead would make every call look like
// somebody else had written, quietly turning the cache off.
assertEquals(1, built)
}
@Test
fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking {
val cache = cache()
var ran = false
val result = cache.withInstance<Unit>(
key = "room",
storedState = null,
build = { null },
save = { },
block = { ran = true }
)
assertNull(result)
assertEquals(false, ran)
assertEquals(0, cache.size())
}
@Test
fun `an instance whose use threw is not handed to the next caller`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
assertFailsWith<IllegalStateException> {
cache.withInstance<Unit>(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { error("decryption blew up half way") }
)
}
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Half-advanced and never persisted: the next caller has to start from
// what is actually on disk, not from whatever the failure left in memory.
assertEquals(2, built)
}
@Test
fun `rooms are cached independently`() = runBlocking {
val cache = cache()
var storedA: String? = "a"
var storedB: String? = "b"
val a = cache.withInstance(
key = "roomA",
storedState = storedA,
build = { Group("a") },
save = { storedA = it },
block = { it }
)
val b = cache.withInstance(
key = "roomB",
storedState = storedB,
build = { Group("b") },
save = { storedB = it },
block = { it }
)
assertEquals(2, cache.size())
assertEquals(false, a === b)
}
}

View File

@@ -0,0 +1,175 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GROUP_SUB_ID_PREFIX
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.giftWrapFilter
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupChunks
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupFilter
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupIdsFrom
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionIndex
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionKey
import press.mantra.compose.network.sockets.NostrIncomingMessage
import press.mantra.compose.network.sockets.endsLiveSubscription
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
class LiveSubscriptionPlanTest {
private val me = "a".repeat(64)
// --- filters ----------------------------------------------------------------------
/**
* The one that will look like an oversight to whoever reads it next.
*
* NIP-59 randomizes a gift wrap's `created_at` into the past, and our own outbound path
* stamps them with `TimeUtils.randomWithTwoDays()` — so a wrap published this second can
* carry a timestamp two days old. A `since` anywhere near the present silently drops a
* large share of genuinely new messages, and the symptom is "some DMs just never arrive":
* no error, no log, nothing to grep for.
*/
@Test
fun `the gift wrap filter carries no since`() {
assertNull(giftWrapFilter(me).since)
}
@Test
fun `the gift wrap filter asks for wraps addressed to us`() {
val filter = giftWrapFilter(me)
assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds)
assertEquals(mapOf("p" to listOf(me)), filter.tags)
assertEquals(100, filter.limit)
}
/**
* `limit` is not a cap on the subscription — NIP-01 scopes it to the stored events a relay
* sends before EOSE, explicitly not to the stream after it. It bounds what a reconnect
* costs; it does not bound what arrives live.
*/
@Test
fun `the group filter asks for the given groups, and also carries no since`() {
val filter = groupFilter(listOf("group-a", "group-b"))
assertEquals(listOf(GroupEvent.KIND), filter.kinds)
assertEquals(mapOf("h" to listOf("group-a", "group-b")), filter.tags)
assertEquals(500, filter.limit)
assertNull(filter.since)
}
// --- which groups are watched -----------------------------------------------------
@Test
fun `only MLS rooms are watched`() {
val rooms = listOf(
room(id = "mls", mlsGroupState = "state"),
// A NIP-17 room has no group state; its messages arrive as gift wraps instead, so
// h-tag subscribing to it would ask for events that do not exist.
room(id = "nip17", mlsGroupState = null),
)
assertEquals(listOf("mls"), groupIdsFrom(rooms))
}
/**
* A room we left keeps its history locally and must stop pulling new messages. Getting
* this wrong is not merely wasteful: it means still receiving from a group we are no
* longer a member of.
*/
@Test
fun `rooms we have left or deleted are not watched`() {
val rooms = listOf(
room(id = "here", mlsGroupState = "state"),
room(id = "left", mlsGroupState = "state", leftGroupAt = Instant.fromEpochSeconds(10)),
room(id = "gone", mlsGroupState = "state", deletedAt = Instant.fromEpochSeconds(10)),
)
assertEquals(listOf("here"), groupIdsFrom(rooms))
}
/**
* Sorted, so the same membership in a different row order is the same value and the
* `distinctUntilChanged` upstream of the reconcile does not re-send a filter that has not
* actually changed.
*/
@Test
fun `group ids come out sorted`() {
val rooms = listOf("c", "a", "b").map { room(id = it, mlsGroupState = "state") }
assertEquals(listOf("a", "b", "c"), groupIdsFrom(rooms))
}
// --- chunking and subscription ids -------------------------------------------------
@Test
fun `groups are chunked to bound the size of one filter's tag array`() {
val chunks = groupChunks((1..250).map { "group-$it" })
assertEquals(3, chunks.size)
assertEquals(listOf(100, 100, 50), chunks.map { it.size })
}
@Test
fun `no groups means no subscriptions`() {
assertTrue(groupChunks(emptyList()).isEmpty())
}
@Test
fun `a subscription key round-trips back to its chunk index`() {
val key = subscriptionKey(relayUrl = "wss://relay.example.com", subId = groupSubId(3))
assertEquals("wss://relay.example.com|${GROUP_SUB_ID_PREFIX}3", key)
assertEquals(3, subscriptionIndex(key))
}
/**
* `subscriptionIndex` decides which subscriptions get closed when membership shrinks —
* anything at or past the new chunk count goes. Unparseable therefore has to read as "past
* the end", so a key nobody recognises is closed rather than kept open forever.
*/
@Test
fun `an unrecognisable key sorts past the end so it gets closed`() {
assertEquals(Int.MAX_VALUE, subscriptionIndex("wss://relay.example.com|$GIFT_WRAP_SUB_ID"))
assertEquals(Int.MAX_VALUE, subscriptionIndex("nonsense"))
}
// --- what ends a live subscription -------------------------------------------------
@Test
fun `EOSE does not end a live subscription, CLOSED does`() {
assertFalse(NostrIncomingMessage.EoseMessage(subscriptionId = "s").endsLiveSubscription())
assertFalse(NostrIncomingMessage.EventMessage(subscriptionId = "s").endsLiveSubscription())
assertFalse(NostrIncomingMessage.NoticeMessage(message = "hi").endsLiveSubscription())
assertTrue(
NostrIncomingMessage.ClosedMessage(subscriptionId = "s", message = "rate-limited")
.endsLiveSubscription()
)
}
private fun room(
id: String,
mlsGroupState: String?,
leftGroupAt: Instant? = null,
deletedAt: Instant? = null,
) = LocalChatRoom(
chatRoom = ChatRoom(
id = id,
userPublicKey = me,
subject = null,
description = null,
mlsGroupState = mlsGroupState,
leftGroupAt = leftGroupAt,
deletedAt = deletedAt,
)
)
}

View File

@@ -0,0 +1,428 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId
import press.mantra.compose.network.relays.LiveSubscriptionTransport
import press.mantra.compose.network.sockets.NostrIncomingMessage
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* The requirement this whole change exists to meet: the group subscription has to follow group
* membership, without anyone remembering to call a subscribe function when a group is joined.
*
* The failure this guards against is entirely silent. A group whose id never makes it into the
* `#h` filter is not an error anywhere — it is a conversation that simply never delivers, on a
* screen that looks exactly like an empty one.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class LiveSubscriptionReconcileTest {
private val keyPair = KeyPair()
private val me = keyPair.pubKey.toHexString()
@Test
fun `opens a group subscription for the groups we are in`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b")))
val running = start(transport, rooms)
settle()
val opened = transport.opened.single { it.reqCommand.subId == groupSubId(0) }
assertEquals(
mapOf("h" to listOf("group-a", "group-b")),
opened.reqCommand.filters.single().tags,
)
running.cancelAndJoin()
}
/**
* The loop that closes: a Welcome arrives on the gift wrap subscription, a ChatRoom row is
* written, the room list re-emits, and the filter widens — with no chat screen involved.
*
* It has to widen *in place*. Closing and re-opening would drop the live tail of every
* group already in that chunk for as long as the round trip takes, so joining one group
* would briefly stop delivery on all the others.
*/
@Test
fun `joining a group widens the filter without reopening the subscription`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a")))
val running = start(transport, rooms)
settle()
val opensBefore = transport.opened.count { it.reqCommand.subId == groupSubId(0) }
rooms.value = listOf(room("group-a"), room("group-new"))
settle()
val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) }
assertEquals(
mapOf("h" to listOf("group-a", "group-new")),
updated.reqCommand.filters.single().tags,
)
assertEquals(
opensBefore,
transport.opened.count { it.reqCommand.subId == groupSubId(0) },
"widening must not re-open the subscription",
)
running.cancelAndJoin()
}
@Test
fun `leaving a group drops it from the filter`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b")))
val running = start(transport, rooms)
settle()
rooms.value = listOf(room("group-a"), room("group-b", leftGroupAt = Instant.fromEpochSeconds(1)))
settle()
val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) }
assertEquals(mapOf("h" to listOf("group-a")), updated.reqCommand.filters.single().tags)
running.cancelAndJoin()
}
@Test
fun `leaving every group closes the subscription`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a")))
val running = start(transport, rooms)
settle()
rooms.value = emptyList()
settle()
assertTrue(transport.closed.any { it.subId == groupSubId(0) })
running.cancelAndJoin()
}
/**
* Membership churn inside the debounce window must collapse. Joining a group writes the
* room, its participants and placeholder profiles in quick succession, each of which
* re-emits the list — so without this a single join re-sends the filter several times.
*/
@Test
fun `rapid membership changes collapse into one update`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a")))
val running = start(transport, rooms)
settle()
val updatesBefore = transport.updated.size
rooms.value = listOf(room("group-a"), room("group-b"))
advanceTimeBy(100)
rooms.value = listOf(room("group-a"), room("group-b"), room("group-c"))
advanceTimeBy(100)
rooms.value = listOf(room("group-a"), room("group-b"), room("group-c"), room("group-d"))
settle()
assertEquals(1, transport.updated.size - updatesBefore, "expected one update, not three")
running.cancelAndJoin()
}
@Test
fun `a NIP-17 room never becomes a group subscription`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("dm-room", mlsGroupState = null)))
val running = start(transport, rooms)
settle()
assertTrue(transport.opened.none { it.reqCommand.subId.startsWith("live-groups-") })
running.cancelAndJoin()
}
// --- what arrives on a subscription -------------------------------------------------
@Test
fun `events are stored against the relay they arrived from`() = runTest {
val transport = FakeTransport()
val nostr = RecordingNostrRepository()
val running = start(transport, MutableStateFlow(emptyList()), nostr)
settle()
val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
subscription.messages.emit(
NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("ev-1"))
)
settle()
val saved = nostr.saved.single()
assertEquals("ev-1", saved.first)
assertEquals(subscription.relayUrl, saved.second)
running.cancelAndJoin()
}
/** EOSE is a marker, not an end: what follows it is the whole point. */
@Test
fun `an event after EOSE is still stored`() = runTest {
val transport = FakeTransport()
val nostr = RecordingNostrRepository()
val running = start(transport, MutableStateFlow(emptyList()), nostr)
settle()
val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
subscription.messages.emit(NostrIncomingMessage.EoseMessage(subscriptionId = GIFT_WRAP_SUB_ID))
subscription.messages.emit(
NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("after-eose"))
)
settle()
assertEquals(listOf("after-eose"), nostr.saved.map { it.first })
running.cancelAndJoin()
}
@Test
fun `a relay that closes the subscription gets it re-opened`() = runTest {
val transport = FakeTransport()
val running = start(transport, MutableStateFlow(emptyList()))
settle()
val first = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
first.messages.emit(
NostrIncomingMessage.ClosedMessage(subscriptionId = GIFT_WRAP_SUB_ID, message = "shutting down")
)
settle(1.seconds)
assertEquals(
1,
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
"should still be inside the back-off, not hammering the relay",
)
settle(30.seconds)
assertEquals(
2,
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
"a CLOSED subscription should have been re-opened once the back-off elapsed",
)
running.cancelAndJoin()
}
/**
* Back-pressure is the one refusal that must NOT be answered promptly — opening another
* subscription is exactly what the relay just asked us to stop doing.
*/
@Test
fun `a rate-limited close waits far longer before re-opening`() = runTest {
val transport = FakeTransport()
val running = start(transport, MutableStateFlow(emptyList()))
settle()
transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }.messages.emit(
NostrIncomingMessage.ClosedMessage(
subscriptionId = GIFT_WRAP_SUB_ID,
message = "rate-limited: too many concurrent REQs",
)
)
settle(30.seconds)
assertEquals(
1,
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
"must not re-open within the window a plain failure would have",
)
// ...but it is a back-off, not a give-up.
settle(5.minutes)
assertEquals(
2,
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
"the subscription should come back once the relay has had its breathing room",
)
running.cancelAndJoin()
}
// --- lifecycle -----------------------------------------------------------------------
@Test
fun `backgrounding closes the subscriptions and foregrounding rebuilds them`() = runTest {
val transport = FakeTransport()
val foreground = MutableStateFlow(true)
val running = start(transport, MutableStateFlow(emptyList()), isForeground = foreground)
settle()
val openedWhileForeground = transport.opened.size
assertTrue(openedWhileForeground > 0)
foreground.value = false
settle()
assertTrue(transport.closed.any { it.subId == GIFT_WRAP_SUB_ID }, "should have said goodbye")
foreground.value = true
settle()
assertTrue(transport.opened.size > openedWhileForeground, "should have re-subscribed")
running.cancelAndJoin()
}
/**
* A live subscription covers the window we are connected for; it cannot answer for the gap
* we were away. That is negentropy's job, and returning to the foreground is precisely
* when it needs asking.
*/
@Test
fun `foregrounding reconnects and queues a catch-up reconciliation`() = runTest {
val transport = FakeTransport()
val nostr = RecordingNostrRepository()
val running = start(transport, MutableStateFlow(listOf(room("group-a"))), nostr)
settle()
assertEquals(1, transport.reconnects, "the socket must not be trusted after a gap")
val purposes = nostr.queued.map { it.purpose }.toSet()
assertTrue("chat" in purposes, "gift wraps should be reconciled")
assertTrue("mlsMessages" in purposes, "group events should be reconciled")
running.cancelAndJoin()
}
// --- harness -------------------------------------------------------------------------
/**
* Advances virtual time by exactly [duration] and runs what that makes due.
*
* Deliberately not `advanceUntilIdle()`: that runs until nothing is scheduled at all,
* which means it fast-forwards through *any* pending delay — including the five-minute
* back-off this suite needs to assert has NOT elapsed. A test that cannot tell "waited"
* from "did not wait" cannot test a back-off at all.
*/
private fun kotlinx.coroutines.test.TestScope.settle(duration: Duration = 1.seconds) {
advanceTimeBy(duration)
runCurrent()
}
private fun kotlinx.coroutines.test.TestScope.start(
transport: FakeTransport,
rooms: MutableStateFlow<List<LocalChatRoom>>,
nostr: RecordingNostrRepository = RecordingNostrRepository(),
isForeground: MutableStateFlow<Boolean> = MutableStateFlow(true),
) = launch {
LiveSubscriptionManager(
relaysSocketManager = transport,
nostrRepository = nostr,
chatRepository = FakeChatRepository(rooms),
isForeground = isForeground,
).observe(keyPair)
}
private fun room(
id: String,
mlsGroupState: String? = "state",
leftGroupAt: Instant? = null,
) = LocalChatRoom(
chatRoom = ChatRoom(
id = id,
userPublicKey = me,
subject = null,
description = null,
mlsGroupState = mlsGroupState,
leftGroupAt = leftGroupAt,
)
)
private fun event(id: String) = NostrEvent(
id = id,
pubKey = me,
createdAt = Instant.fromEpochSeconds(1_000),
kind = 1059,
tags = emptyArray(),
content = "",
sig = "",
)
}
private class OpenedSubscription(val reqCommand: ReqCmd, val relayUrl: String) {
val messages = MutableSharedFlow<NostrIncomingMessage>(extraBufferCapacity = 32)
}
private class FakeTransport : LiveSubscriptionTransport {
val opened = mutableListOf<OpenedSubscription>()
val updated = mutableListOf<OpenedSubscription>()
val closed = mutableListOf<ClosedSubscription>()
var reconnects = 0
data class ClosedSubscription(val subId: String, val relayUrl: String)
override suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow<NostrIncomingMessage> =
OpenedSubscription(reqCommand, relayUrl).also { opened += it }.messages.asSharedFlow()
override suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) {
updated += OpenedSubscription(reqCommand, relayUrl)
}
override suspend fun closeLiveSubscription(subId: String, relayUrl: String) {
closed += ClosedSubscription(subId, relayUrl)
}
override suspend fun reconnectAll() {
reconnects++
}
}
private class FakeChatRepository(
private val rooms: StateFlow<List<LocalChatRoom>>,
) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> = rooms
override suspend fun getChatRoomListByPublicKey(publicKey: String): List<LocalChatRoom> = rooms.value
}
private class RecordingNostrRepository : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
/** (event id, relay it arrived from) */
val saved = mutableListOf<Pair<String, String>>()
val queued = mutableListOf<NegentropySynchronizeRequest>()
override suspend fun saveNostrEvent(
nostrEvent: NostrEvent,
relayURL: String,
synchronizationRelayURLs: List<String>,
level: Int,
activeKeyPair: KeyPair,
) {
saved += nostrEvent.id to relayURL
}
override suspend fun queueNegentropySynchronizeRequest(
negentropySynchronizeRequests: List<NegentropySynchronizeRequest>,
) {
queued += negentropySynchronizeRequests
}
}

View File

@@ -0,0 +1,82 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The one hole deliberately left in MIP-03's author check, and its edges.
*
* MIP-03 requires an inner application event's pubkey to equal the MLS sender's credential
* identity, which is what stops a member minting events attributed to somebody else. A
* gift wrap cannot satisfy it -- its pubkey is a throwaway key that names nobody -- so
* kind:1059 is exempt, and the authorship claim moves to the signed seal inside.
*
* The exemption is the most dangerous line in this feature. Widened to another kind, or
* stripped of its kind guard, it hands every member of every group the ability to publish
* events as anybody, and nothing else in the pipeline would notice. These tests exist so
* that widening it fails here rather than in a group.
*/
class MarmotMip03CarveOutTest {
private val sender = "a".repeat(64)
private val somebodyElse = "b".repeat(64)
private val throwaway = "c".repeat(64)
@Test
fun `an ordinary message from its own author is accepted`() {
assertNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, sender))
}
@Test
fun `an ordinary message claiming somebody else is rejected`() {
val reason = MarmotInboundManager.mip03Rejection(ChatEvent.KIND, somebodyElse, sender)
assertNotNull(reason)
assertTrue(reason.startsWith("MIP-03"), "expected the MIP-03 rejection, got: $reason")
}
@Test
fun `a gift wrap is accepted despite naming nobody`() {
// The carve-out. The wrap's pubkey is a throwaway key and matches no member, which
// is the point of it; what authenticates the sender is the MLS frame, and what
// authenticates the author is the seal inside. See MarmotDirectMessage.classify.
assertNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, sender))
}
@Test
fun `the carve-out does not extend to any other kind`() {
// Every kind carried as an application payload today, plus the seal and rumor
// kinds a gift wrap contains -- none of which should ever arrive unwrapped, and
// all of which would be a way to launder an author if the guard were widened.
val kinds = listOf(
ChatEvent.KIND,
ChatMessageEvent.KIND,
SealedRumorEvent.KIND,
GiftWrapEvent.KIND - 1,
GiftWrapEvent.KIND + 1,
0,
1,
)
kinds.forEach { kind ->
assertNotNull(
MarmotInboundManager.mip03Rejection(kind, somebodyElse, sender),
"kind $kind was allowed to claim an author it does not own",
)
}
}
@Test
fun `a sender with no credential identity is rejected, wrap or not`() {
// Required rather than merely compared: with no identity there is nothing to
// attribute a line to, and for a gift wrap there is nothing to check the seal
// against either. Both kinds must fail, not just the one that does a comparison.
assertNotNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, null))
assertNotNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, null))
}
}

View File

@@ -0,0 +1,227 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
import fr.acinq.bitcoin.crypto.frost.TweakCache
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.nip30303.ArtifactEvent
/**
* An artifact the group signed, from proposal to rows, against real FROST.
*
* Adding an artifact used to write a row and submit it; the row said the
* submitter wrote it, because they had. Now the group signs it, and the claim
* this test exists to hold is that the artifact every device ends up with is
* the group's: authored by the threshold key, carrying a signature that
* verifies, with an id every member arrives at independently.
*
* None of that is visible when it breaks. A row whose author is the proposer
* looks exactly like a row whose author is the group -- both are opaque hex --
* and a group that disagrees about the id has two artifacts that look like one.
*/
class SignedArtifactTest {
private val participants = 3
private val threshold = 2
private val chatRoomId = "room"
/** The member who filled in the form. Nothing they own should end up on the row. */
private val proposer = "9".repeat(64)
private val dialectId = "b".repeat(64)
/** Stands in for a completed ceremony; the test is about what gets signed, not the DKG. */
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
private fun proposalTemplate(versionLabel: String = "1.0") = ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = versionLabel,
createdAt = 1_700_000_000L,
)
/**
* Exactly what `FrostSigningManager.unsignedEventOf` does, and it must stay
* exactly that: the proposer's fields re-authored under the group's key.
*/
private fun unsignedEventOf(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>) = Event(
id = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = groupPubKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = chatRoomId,
coordinatorPublicKey = proposer,
userPublicKey = proposer,
dkgSessionId = "k".repeat(64),
threshold = threshold,
participantCount = participants,
signerId = 0,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
)
/** A quorum signing the session's event, in the manager's order. */
private fun groupSignature(session: FrostSigningSession): String {
val message = ByteVector(session.eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val signingSession = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = tweakCache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
signingSession.sign(
nonces[position].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
}
/** Everything from the form to the row a device holds afterwards. */
private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent {
val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel)))
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
return ArtifactEvent(
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
)
}
@Test
fun `the artifact the group signs is authored by the group, not the proposer`() {
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
assertNotNull(artifact)
assertEquals(groupPubKey, artifact.publicKey)
assertNotEquals(proposer, artifact.publicKey)
}
@Test
fun `the row's id is the id the group put its signature to`() {
// Every device builds this row from the same signed event, so the id has
// to be the one that was signed rather than anything recomputed from the
// proposer. Otherwise members converge on nothing and each holds its own
// copy of what is meant to be one artifact.
val session = sessionOver(unsignedEventOf(proposalTemplate()))
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
val artifact = MantraArtifact.fromArtifactEvent(
ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig),
chatRoomId
)
assertEquals(session.eventId, artifact?.id)
}
@Test
fun `the signature on the row verifies against the row's own id and author`() {
// The payoff of signing rather than submitting: the row carries proof the
// group made it, checkable by anybody holding it.
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
assertNotNull(artifact)
assertTrue(
Nip01Crypto.verify(
signature = artifact.signature.hexToByteArray(),
hash = artifact.id.hexToByteArray(),
pubKey = artifact.publicKey.hexToByteArray()
),
"an artifact row should carry a signature the group's key made over its own id"
)
}
@Test
fun `what the form asked for is what the group signed`() {
// The fields travel as tags through a session that knows nothing about
// artifacts. Anything dropped in there is signed away silently.
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
assertEquals("In Detention", artifact?.name)
assertEquals("https://example.com/in-detention", artifact?.url)
assertEquals("private", artifact?.visibility)
assertEquals("cc", artifact?.license)
assertEquals(dialectId, artifact?.dialectId)
}
@Test
fun `the artifact arrives with the first version hanging off it`() {
// Nothing sends this row: each device derives it from the artifact it
// just applied. A chapter attaches to a version rather than to an
// artifact, so an artifact that arrives without one is inert.
val signed = signedArtifactEvent(versionLabel = "First Edition")
val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId)
val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId)
assertNotNull(version)
assertEquals(artifact?.id, version.artifactId)
assertEquals("First Edition", version.versionLabel)
assertEquals(groupPubKey, version.publicKey)
// Derived, not signed: the group signed the artifact that declares it.
assertEquals("", version.signature)
}
}

View File

@@ -0,0 +1,58 @@
package press.mantra.compose.network.relays
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Pins which CLOSED reasons mean "ease off".
*
* The classification decides what happens next and is expensive to get wrong in both
* directions: a refusal read as a transient failure is answered by opening another
* subscription, which is how one "too many concurrent REQs" becomes a flood of them, while an
* unsupported filter read as back-pressure leaves a subscription shut for five minutes over a
* problem no amount of waiting fixes.
*/
class RelayBackPressureTest {
@Test
fun `recognises the NIP-01 machine-readable prefix`() {
assertTrue(isRelayBackPressure("rate-limited: slow down there chief"))
}
@Test
fun `recognises the free-text forms relays actually send`() {
listOf(
"ERROR: too many concurrent REQs",
"rate limit exceeded",
"Please slow down",
"TOO MANY SUBSCRIPTIONS",
"maximum concurrent subscriptions reached",
).forEach { reason ->
assertTrue(isRelayBackPressure(reason), "expected back-pressure: $reason")
}
}
@Test
fun `does not read a refusal we cannot wait out as back-pressure`() {
listOf(
"auth-required: we can't serve DMs to unauthenticated users",
"unsupported: filter contains unknown tag",
"invalid: filter is empty",
"error: negentropy disabled",
"blocked: you are not allowed to write here",
).forEach { reason ->
assertFalse(isRelayBackPressure(reason), "did not expect back-pressure: $reason")
}
}
/**
* A CLOSED with no reason at all is common. Treating it as back-pressure would mean the
* least informative refusal produced the longest possible outage.
*/
@Test
fun `a missing or empty reason is not back-pressure`() {
assertFalse(isRelayBackPressure(null))
assertFalse(isRelayBackPressure(""))
}
}

View File

@@ -0,0 +1,392 @@
package press.mantra.compose.network.relays
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import press.mantra.compose.network.dto.RelayDTO
import press.mantra.compose.network.sockets.NostrIncomingMessage
import press.mantra.compose.network.sockets.NostrSocketClient
import press.mantra.compose.network.sockets.NostrSocketClientFactory
import press.mantra.compose.network.sockets.SocketConnectionClosedCallback
import press.mantra.compose.network.sockets.SocketConnectionOpenedCallback
import press.mantra.compose.network.sockets.SocketConnectionReopenedCallback
import press.mantra.compose.repository.CachingImportRepository
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The pool's half of surviving a dropped socket.
*
* A subscription that outlives its socket only works if the pool remembers what that socket was
* carrying and hands it back on reconnect. None of that is observable from the outside — no
* return value changes, nothing throws — so a regression here looks like "messages stopped
* arriving after a tunnel", hours later, on someone else's phone.
*
* The other half is that a relay answers a repeated REQ on an existing subscription id by
* replacing that subscription's filter, which is what makes replay a send rather than a
* close-and-reopen.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RelayPoolSubscriptionTest {
private val relayUrl = "wss://relay.example.com"
private val otherRelayUrl = "wss://other.example.com"
private fun req(subId: String, kind: Int = 1) =
ReqCmd(subId = subId, filters = listOf(Filter(kinds = listOf(kind))))
@Test
fun `a query is retained, and replayed when its socket comes back`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
val socket = factory.only()
assertEquals(1, socket.sent.size, "the REQ should have gone out once")
assertTrue(socket.autoReconnect, "a relay carrying a subscription is worth reconnecting")
socket.sent.clear()
factory.reopen(relayUrl)
assertEquals(1, socket.sent.size, "the REQ should have been replayed")
assertTrue(socket.sent.single().contains("sub-1"))
}
@Test
fun `a closed query is forgotten, and the socket stops reconnecting for it`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl)
val socket = factory.only()
assertFalse(socket.autoReconnect, "nothing is open on this relay any more")
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.isEmpty(), "a closed subscription must not come back on reconnect")
}
/**
* Closing one of two must not drop the other — the bug this guards is a shared socket
* quietly losing its remaining subscription because a sibling finished first.
*/
@Test
fun `closing one subscription leaves the rest of that relay's alone`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
pool.query(req("sub-2", kind = 7), relayUrl)
pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl)
val socket = factory.only()
assertTrue(socket.autoReconnect, "sub-2 is still open")
socket.sent.clear()
factory.reopen(relayUrl)
assertEquals(1, socket.sent.size)
assertTrue(socket.sent.single().contains("sub-2"))
}
/**
* Negentropy is stateful: NEG-OPEN carries a fingerprint of the local set and every round
* depends on the last. Replaying one mid-exchange would reconcile against a conversation
* the relay is no longer having, so an interrupted exchange is abandoned and re-queued
* instead.
*/
@Test
fun `a negentropy exchange is never replayed`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.negentropySync(
NegOpenCmd(subId = "neg-1", filter = Filter(kinds = listOf(1)), initialMessage = "6100"),
relayUrl,
)
val socket = factory.only()
assertEquals(1, socket.sent.size, "NEG-OPEN still goes out")
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.isEmpty(), "a half-finished reconciliation must not be resumed")
}
@Test
fun `a live subscription is retained like any other`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
val socket = factory.only()
assertTrue(socket.autoReconnect)
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.single().contains("live-giftwrap"))
}
/**
* Widening a live subscription has to replace what gets replayed, not just what is on the
* wire now. Retaining the old filter would mean a reconnect quietly restored a
* subscription the caller had already moved on from — a group you just joined going
* silent the first time you walked through a tunnel.
*/
@Test
fun `updating a live subscription replaces what a reconnect replays`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl)
pool.updateLiveSubscription(
ReqCmd(
subId = "live-groups-0",
filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))),
),
relayUrl,
)
val socket = factory.only()
socket.sent.clear()
factory.reopen(relayUrl)
val replayed = socket.sent.single()
assertTrue(replayed.contains("group-b"), "expected the current filter, got: $replayed")
}
/**
* An update that cannot be sent must still be recorded. Throwing instead would leave the
* previous filter retained, which is the one outcome worse than not sending at all.
*/
@Test
fun `an update whose send fails is still retained for the reconnect`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl)
val socket = factory.only()
socket.failSends = true
pool.updateLiveSubscription(
ReqCmd(
subId = "live-groups-0",
filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))),
),
relayUrl,
)
socket.failSends = false
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.single().contains("group-b"))
}
@Test
fun `dropping a relay forgets what it was carrying`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
pool.changeRelays(listOf(RelayDTO(url = otherRelayUrl, read = true, write = true)))
val dropped = factory.forUrl(relayUrl)
dropped.sent.clear()
factory.reopen(relayUrl)
assertTrue(dropped.sent.isEmpty(), "a relay we removed should not be re-subscribed")
}
@Test
fun `closing the pool forgets everything`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
pool.closePool()
val socket = factory.only()
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.isEmpty())
}
/** Retention is per relay: one relay's reconnect must not re-send another's REQ. */
@Test
fun `replay is scoped to the relay that reconnected`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-here"), relayUrl)
pool.query(req("sub-there"), otherRelayUrl)
factory.forUrl(relayUrl).sent.clear()
factory.forUrl(otherRelayUrl).sent.clear()
factory.reopen(relayUrl)
assertTrue(factory.forUrl(relayUrl).sent.single().contains("sub-here"))
assertTrue(factory.forUrl(otherRelayUrl).sent.isEmpty())
}
/**
* The semantic the whole change rests on. For a one-shot request EOSE is the end; for a
* live one it is only the boundary between the history a relay had stored and the tail it
* will now stream. Ending there is precisely what made every chat sync a poll.
*/
@Test
fun `a live subscription keeps delivering after EOSE`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
val received = mutableListOf<NostrIncomingMessage>()
val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
flow.collect { received += it }
}
val socket = factory.only()
socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "live-giftwrap"))
socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "live-giftwrap"))
assertEquals(2, received.size, "the event after EOSE should still have arrived")
assertTrue(collecting.isActive, "a live subscription ends when its collector stops, not at EOSE")
collecting.cancel()
}
/** The contrast, so the two rules cannot silently converge. */
@Test
fun `a one-shot query still ends at EOSE`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
val received = mutableListOf<NostrIncomingMessage>()
val flow = pool.query(req("sub-1"), relayUrl)
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
flow.collect { received += it }
}
val socket = factory.only()
socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "sub-1"))
socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "sub-1"))
assertEquals(1, received.size, "nothing should arrive after the EOSE that ended it")
assertFalse(collecting.isActive, "the collector should have completed")
}
/**
* A NOTICE carries no subscription id, so the socket hands it to every collector. It is
* admitted deliberately — it is the only signal some relays give for "negentropy disabled"
* — but it must stay advisory, never terminal, or one relay's complaint would tear down
* every unrelated subscription on that socket.
*/
@Test
fun `a NOTICE reaches a live subscription without ending it`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
val received = mutableListOf<NostrIncomingMessage>()
val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
flow.collect { received += it }
}
factory.only().deliver(NostrIncomingMessage.NoticeMessage(message = "restricted: slow"))
assertEquals(1, received.size)
assertTrue(collecting.isActive)
collecting.cancel()
}
private fun kotlinx.coroutines.test.TestScope.pool(factory: FakeSocketClientFactory) =
RelayPool(
nostrSocketClientFactory = factory,
cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY,
// Unconfined so the pool's launched work — status updates, and the replay a
// reconnect triggers — has run by the time the call that started it returns.
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
)
}
private class FakeSocketClientFactory : NostrSocketClientFactory {
private val clients = mutableMapOf<String, FakeNostrSocketClient>()
private val reopenCallbacks = mutableMapOf<String, SocketConnectionReopenedCallback?>()
override fun create(
wssUrl: String,
incomingCompressionEnabled: Boolean,
onSocketConnectionOpened: SocketConnectionOpenedCallback?,
onSocketConnectionClosed: SocketConnectionClosedCallback?,
onSocketConnectionReopened: SocketConnectionReopenedCallback?,
): NostrSocketClient {
reopenCallbacks[wssUrl] = onSocketConnectionReopened
return FakeNostrSocketClient(wssUrl).also { clients[wssUrl] = it }
}
fun only(): FakeNostrSocketClient = clients.values.single()
fun forUrl(url: String): FakeNostrSocketClient = clients.getValue(url)
/** Stands in for a socket that dropped and re-established its session. */
fun reopen(url: String) {
reopenCallbacks.getValue(url)?.invoke(url)
}
}
private class FakeNostrSocketClient(override val socketUrl: String) : NostrSocketClient {
val sent = mutableListOf<String>()
var failSends = false
private val _incomingMessages = MutableSharedFlow<NostrIncomingMessage>(extraBufferCapacity = 64)
override val incomingMessages: SharedFlow<NostrIncomingMessage> = _incomingMessages.asSharedFlow()
override var autoReconnect: Boolean = false
override suspend fun close() = Unit
override suspend fun ensureSocketConnectionOrThrow() = Unit
override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) {
if (failSends) throw IllegalStateException("socket is down")
sent += text
}
override suspend fun sendAUTH(signedEvent: JsonObject) = Unit
override suspend fun sendCLOSE(subscriptionId: String) = Unit
override suspend fun sendCOUNT(data: JsonObject): String = "unused"
override suspend fun sendEVENT(signedEvent: JsonObject) = Unit
override suspend fun sendREQ(subscriptionId: String, data: JsonObject) = Unit
/** Stands in for a message arriving on the wire. */
suspend fun deliver(message: NostrIncomingMessage) {
_incomingMessages.emit(message)
}
}

View File

@@ -0,0 +1,73 @@
package press.mantra.compose.network.sockets
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class ReconnectBackoffTest {
private val initial = 1.seconds
private val max = 60.seconds
private fun delay(attempt: Int, jitter: Double = 0.0) =
reconnectDelay(
attempt = attempt,
initialDelay = initial,
maxDelay = max,
jitterFraction = 0.25,
jitter = jitter,
)
@Test
fun `doubles once per previous failure`() {
assertEquals(1.seconds, delay(attempt = 1))
assertEquals(2.seconds, delay(attempt = 2))
assertEquals(4.seconds, delay(attempt = 3))
assertEquals(8.seconds, delay(attempt = 4))
}
@Test
fun `stops growing at the cap`() {
assertEquals(max, delay(attempt = 7))
assertEquals(max, delay(attempt = 50))
}
/**
* The reason [MAX_RECONNECT_EXPONENT] exists. `2.0.pow(4000)` is `Infinity`, and
* `Duration * Double` throws on it — so without the clamp a socket that had been failing
* for long enough turned its own reconnect loop into a crash loop, at roughly the moment
* the network was least likely to recover unaided.
*/
@Test
fun `survives an attempt count large enough to overflow the doubling`() {
assertEquals(max, delay(attempt = 4_000))
assertEquals(max, delay(attempt = Int.MAX_VALUE))
}
/** Defensive: a caller that starts counting at zero should still get a usable delay. */
@Test
fun `treats a non-positive attempt as the first one`() {
assertEquals(initial, delay(attempt = 0))
assertEquals(initial, delay(attempt = -3))
}
@Test
fun `adds at most the jitter fraction on top, never subtracts`() {
assertEquals(4.seconds, delay(attempt = 3, jitter = 0.0))
assertEquals(5.seconds, delay(attempt = 3, jitter = 1.0))
val midway = delay(attempt = 3, jitter = 0.5)
assertTrue(midway > 4.seconds && midway < 5.seconds, "expected 4s..5s, got $midway")
}
/**
* Jitter is applied after the cap, so a capped delay still spreads: relays all drop at the
* same moment when the network does, and identical waits would bring them back in lockstep
* for as long as the outage lasted.
*/
@Test
fun `jitter still spreads a capped delay`() {
assertTrue(delay(attempt = 50, jitter = 1.0) > max)
}
}

View File

@@ -0,0 +1,107 @@
package press.mantra.compose.nostr
import press.mantra.compose.database.model.ChatMessage
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Whether an encrypted group event actually leaves the device.
*
* The bug this pins did not throw, log, or fail a build. `MarmotOutboundDao`
* wrote the broadcast rows inside `chatMessageOrNull?.let { }`, so an event with
* no chat line pointing at it was MLS-encrypted, wrapped, persisted, its queue
* row marked processed -- and never sent. FROST signing proposals and the room's
* key announcement are exactly that: traffic the room generates, which
* deliberately writes no ChatMessage of its own. Every proposal was silently
* delivered to nobody.
*
* The DAO around this is Room-backed and cannot be stood up here, which is how
* the gate survived unnoticed in the first place. So the decision is tested
* where it can be seen, and the DAO does nothing with it but write what it says.
*/
class MarmotDeliveryTest {
private val groupEventId = "a".repeat(64)
private val relays = Relays.DefaultDMRelayList
private val chatLine = ChatMessage(
id = 42L,
senderPublicKey = "b".repeat(64),
isUserMessage = true,
giftWrapPayloadId = null,
marmotGroupEventId = null,
marmotInnerEventId = "c".repeat(64),
chatRoomId = "room",
content = "the vote is at six",
)
@Test
fun `a signing proposal goes out, though nothing in the chat points at it`() {
// The regression. A FROST message has no ChatMessage by design -- the manager
// writes its own transcript lines from what arrives, so a row here would be a
// second, worse account of the same thing -- and that must not be the reason the
// group never hears about it.
val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null)
assertTrue(delivery.isProtocolTraffic)
assertEquals(relays.size, delivery.broadcasts.size)
assertTrue(delivery.broadcasts.isNotEmpty(), "an event on disk and going nowhere")
}
@Test
fun `the send does not depend on the transcript`() {
// Said as directly as it can be said: the two questions are independent. Anything
// that makes a broadcast conditional on a chat line fails here.
val protocol = MarmotDelivery.plan(groupEventId, relays, chatMessage = null)
val spoken = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine)
assertContentEquals(
protocol.broadcasts.map { it.relayURL },
spoken.broadcasts.map { it.relayURL },
)
assertEquals(protocol.broadcasts.size, spoken.broadcasts.size)
}
@Test
fun `every relay gets a request, naming the event`() {
val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine)
assertContentEquals(
relays.map { it.url },
delivery.broadcasts.map { it.relayURL },
)
assertTrue(delivery.broadcasts.all { it.nostrEventId == groupEventId })
}
@Test
fun `requests are queued pending, which is all the broadcaster looks at`() {
// `observeBroadcastNostrEventRequestsByStatus("pending")` is the only thing that
// picks these up. A request written in any other state is as unsent as no request.
val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null)
assertTrue(delivery.broadcasts.all { it.status == "pending" })
}
@Test
fun `a member's message is linked to its chat line`() {
// The bookkeeping that legitimately does depend on there being a chat line: the
// transcript needs to know which event carried the words, so a sent message can
// be shown as sent.
val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine)
assertFalse(delivery.isProtocolTraffic)
assertEquals(chatLine.id, delivery.transcriptChatMessageId)
}
@Test
fun `no relays is the only way an event stays home`() {
// Worth pinning as the single legitimate empty case, so an empty broadcast list
// is always read as "nowhere to send it" and never as "nothing to send".
val delivery = MarmotDelivery.plan(groupEventId, relays = emptyList(), chatMessage = chatLine)
assertTrue(delivery.broadcasts.isEmpty())
assertEquals(chatLine.id, delivery.transcriptChatMessageId)
}
}

View File

@@ -0,0 +1,276 @@
package press.mantra.compose.nostr
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* What a device decides to do with an arriving wrap.
*
* This is where the check that replaces MIP-03 for kind:1059 is tested. The wrap itself
* names nobody, so everything a transcript says about who sent a private message rests on
* one comparison -- the seal's author against the identity MLS authenticated -- and if it
* stops being made, a member can have their words attributed to somebody else, or
* somebody else's attributed to them. The rest of the inbound path is Room-backed and
* cannot be unit-tested here, which is exactly why the decision was separated from the
* filing of it.
*
* `Ours` carries a second kind of risk. It is indistinguishable from `Unreadable` by
* decryption alone -- we cannot open our own wrap either -- and getting it wrong does not
* fail loudly: it files a placeholder over the only copy of the sender's own words.
*/
class MarmotDirectMessageDeliveryTest {
private val alice = NostrSignerSync(KeyPair())
private val bob = KeyPair()
private val eve = NostrSignerSync(KeyPair())
private val alicePublicKey = alice.pubKey
private val bobPublicKey = bob.pubKey.toHexKey()
private val at = 1_700_000_000L
private val text = "the vote is at six"
private fun wrapFor(
signer: NostrSignerSync = alice,
recipient: String = bobPublicKey,
kind: Int = ChatMessageEvent.KIND,
content: String = text,
): GiftWrapEvent =
MarmotDirectMessage.wrap(
signer = signer,
recipientPublicKey = recipient,
kind = kind,
createdAt = at,
tags = arrayOf(PTag.assemble(recipient, null)),
content = content,
)
@Test
fun `the recipient gets the words`() {
val delivery = MarmotDirectMessage.classify(wrapFor(), bob, alicePublicKey)
val readable = assertIs<MarmotDirectMessage.Delivery.Readable>(delivery)
assertEquals(text, readable.opened.rumor.content)
assertEquals(alicePublicKey, readable.opened.seal.pubKey)
}
@Test
fun `a bystander gets a line with nothing in it`() {
val carol = KeyPair()
assertEquals(
MarmotDirectMessage.Delivery.Unreadable,
MarmotDirectMessage.classify(wrapFor(), carol, alicePublicKey),
)
}
@Test
fun `our own message is ours, even though we cannot open it`() {
// The case that destroys data if it is missed. Alice cannot decrypt the wrap she
// sent -- its key was discarded -- so nothing about the payload distinguishes this
// from a bystander's view. Only the MLS sender identity does. Were it to come back
// Unreadable, the inbound path would file an empty line over the row sendChatMessage
// wrote, which is the only copy of those words anywhere.
val delivery = MarmotDirectMessage.classify(wrapFor(), alice.keyPair, alicePublicKey)
assertEquals(MarmotDirectMessage.Delivery.Ours, delivery)
}
@Test
fun `ours is decided before anything is opened`() {
// Same as above, from the other direction: a wrap Alice could never open, that is
// not even addressed to her, is still hers if MLS says she sent it. Nothing here
// may depend on decryption succeeding.
val toEve = wrapFor(recipient = eve.pubKey)
assertEquals(
MarmotDirectMessage.Delivery.Ours,
MarmotDirectMessage.classify(toEve, alice.keyPair, alicePublicKey),
)
}
@Test
fun `a message with no sender identity is dropped`() {
// Attribution has no other source. A line that cannot say who sent it must not be
// rendered at all rather than be attributed to the wrap, which names nobody.
val delivery = MarmotDirectMessage.classify(wrapFor(), bob, null)
assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
}
@Test
fun `words sealed by one member and sent by another are dropped`() {
// The check that replaces MIP-03. Eve seals to Bob and sends; if MLS says the
// sender was Alice, the two disagree about who is speaking and the message is not
// rendered as anybody's. Without this, the seal inside is free to claim an author
// the frame does not support.
val delivery = MarmotDirectMessage.classify(wrapFor(signer = eve), bob, alicePublicKey)
val rejected = assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
assertTrue(
rejected.reason.contains(eve.pubKey) && rejected.reason.contains(alicePublicKey),
"the reason should name both parties to the disagreement: ${rejected.reason}",
)
}
@Test
fun `a seal relabelled with another author cannot even be opened`() {
// Eve's seal with Alice's pubkey written over it. This never reaches the author
// check, because NIP-44 derives the conversation key from the pubkey being
// claimed: relabelling a seal makes it undecryptable by the person it was
// encrypted for. The label is bound to the key, not merely asserted alongside it.
//
// So the outcome is Unreadable rather than Rejected -- Bob genuinely cannot read
// it -- and the group sees a private message it cannot open, which is the truth.
val forged = resealedAs(alicePublicKey, sealedBy = eve, to = bob)
assertEquals(
MarmotDirectMessage.Delivery.Unreadable,
MarmotDirectMessage.classify(forged, bob, alicePublicKey),
)
}
@Test
fun `a seal tampered with after signing is dropped`() {
// Alice's own seal with its timestamp altered. Everything the previous test
// relies on still holds -- the pubkey is Alice's, so the content decrypts -- and
// the pubkey matches what MLS says, so the author check passes. Only the
// signature is left, and it is what catches this: the id no longer commits to the
// fields, so verify() fails.
val tampered = tamperedTimestamp(sealedBy = alice, to = bob)
val delivery = MarmotDirectMessage.classify(tampered, bob, alicePublicKey)
val rejected = assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
assertTrue(
rejected.reason.contains("does not verify"),
"expected a signature rejection, got: ${rejected.reason}",
)
}
@Test
fun `a rumor by somebody other than the sealer is dropped`() {
// A correctly signed seal by Alice, wrapped around a rumor claiming to be Eve's.
// Everything outside the innermost layer checks out; the words would be filed
// under whoever the rumor names if this were not caught.
val mismatched = sealAroundForeignRumor(sealedBy = alice, rumorFrom = eve.pubKey, to = bobPublicKey)
val delivery = MarmotDirectMessage.classify(mismatched, bob, alicePublicKey)
val rejected = assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
assertTrue(
rejected.reason.contains("inside a seal by"),
"expected a rumor/seal author mismatch, got: ${rejected.reason}",
)
}
@Test
fun `a rumor of the wrong kind is dropped`() {
// kind:9 is what an ordinary group message uses. Arriving gift wrapped it is not a
// direct message this version knows how to render, and rendering it as one would
// put a group message behind a lock.
val delivery = MarmotDirectMessage.classify(wrapFor(kind = ChatEvent.KIND), bob, alicePublicKey)
val rejected = assertIs<MarmotDirectMessage.Delivery.Rejected>(delivery)
assertTrue(
rejected.reason.contains("${ChatEvent.KIND}"),
"expected the offending kind in the reason, got: ${rejected.reason}",
)
}
/** [sealedBy]'s seal with [claimedAuthor] written over its pubkey, wrapped for [to]. */
private fun resealedAs(
claimedAuthor: String,
sealedBy: NostrSignerSync,
to: KeyPair,
): GiftWrapEvent {
val honest = MarmotDirectMessage.wrap(
signer = sealedBy,
recipientPublicKey = to.pubKey.toHexKey(),
kind = ChatMessageEvent.KIND,
createdAt = at,
tags = emptyArray(),
content = text,
)
val seal = MarmotDirectMessage.open(honest, to)!!.seal
val relabelled =
Event(
id = seal.id,
pubKey = claimedAuthor,
createdAt = seal.createdAt,
kind = SealedRumorEvent.KIND,
tags = seal.tags,
content = seal.content,
sig = seal.sig,
)
return GiftWrapEvent.create(
event = relabelled,
recipientPubKey = to.pubKey.toHexKey(),
createdAt = at,
)
}
/** [sealedBy]'s own seal, its timestamp altered after signing, wrapped for [to]. */
private fun tamperedTimestamp(
sealedBy: NostrSignerSync,
to: KeyPair,
): GiftWrapEvent {
val honest = MarmotDirectMessage.wrap(
signer = sealedBy,
recipientPublicKey = to.pubKey.toHexKey(),
kind = ChatMessageEvent.KIND,
createdAt = at,
tags = emptyArray(),
content = text,
)
val seal = MarmotDirectMessage.open(honest, to)!!.seal
val altered =
Event(
id = seal.id,
pubKey = seal.pubKey,
createdAt = seal.createdAt + 86_400,
kind = SealedRumorEvent.KIND,
tags = seal.tags,
content = seal.content,
sig = seal.sig,
)
return GiftWrapEvent.create(
event = altered,
recipientPubKey = to.pubKey.toHexKey(),
createdAt = at,
)
}
/** A properly signed seal by [sealedBy] whose rumor claims [rumorFrom] wrote it. */
private fun sealAroundForeignRumor(
sealedBy: NostrSignerSync,
rumorFrom: String,
to: String,
): GiftWrapEvent {
val rumor = MarmotDirectMessage.rumor(rumorFrom, ChatMessageEvent.KIND, at, emptyArray(), text)
val seal =
sealedBy.signNormal<SealedRumorEvent>(
createdAt = at,
kind = SealedRumorEvent.KIND,
tags = emptyArray(),
content = sealedBy.nip44Encrypt(rumor.toJson(), to),
)
return GiftWrapEvent.create(event = seal, recipientPubKey = to, createdAt = at)
}
}

View File

@@ -0,0 +1,185 @@
package press.mantra.compose.nostr
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The envelope a direct message travels in, run against real secp256k1 and real NIP-44.
*
* Two of these assert properties that read like bugs and are not. The wrap names nobody,
* so the group can only learn who sent it from the MLS frame around it; and the sender
* cannot reopen their own message, because the key that sealed it was discarded. Both are
* consequences of using a throwaway key, both are documented in
* docs/marmot-direct-messages.md, and both are here so that a later change which quietly
* reverses them fails rather than ships.
*/
class MarmotDirectMessageTest {
private val alice = NostrSignerSync(KeyPair())
private val bob = KeyPair()
private val eve = KeyPair()
private val bobPublicKey = bob.pubKey.toHexKey()
private val at = 1_700_000_000L
private val text = "the vote is at six, do not tell the room"
private fun aliceWrapsForBob(content: String = text): GiftWrapEvent =
MarmotDirectMessage.wrap(
signer = alice,
recipientPublicKey = bobPublicKey,
kind = ChatMessageEvent.KIND,
createdAt = at,
tags = arrayOf(PTag.assemble(bobPublicKey, null)),
content = content,
)
@Test
fun `the recipient reads what the sender wrote`() {
val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob), "Bob could not open a wrap addressed to him")
assertEquals(text, opened.rumor.content)
assertEquals(alice.pubKey, opened.rumor.pubKey)
assertEquals(ChatMessageEvent.KIND, opened.rumor.kind)
}
@Test
fun `the sender cannot reopen their own message`() {
// The throwaway key is gone, so this is unrecoverable by construction. Asserted
// rather than merely documented, because the obvious "fix" -- persisting the
// throwaway private key -- would be strictly worse than the identity-keyed wrap
// this design was chosen over, and would reintroduce the attribution the
// throwaway key exists to remove.
assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), alice.keyPair))
}
@Test
fun `a bystander gets nothing, and no exception`() {
// Null rather than a throw: the caller is midway through processing a kind:445
// that carries a real message for somebody, and an exception would abandon it.
assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), eve))
}
@Test
fun `the wrap names nobody`() {
val first = aliceWrapsForBob()
val second = aliceWrapsForBob()
assertNotEquals(alice.pubKey, first.pubKey, "the wrap is keyed to its sender")
assertNotEquals(bobPublicKey, first.pubKey, "the wrap is keyed to its recipient")
assertNotEquals(first.pubKey, second.pubKey, "the throwaway key is being reused")
// Signed by the throwaway key, per NIP-59. The signature attributes nothing --
// the signer is meaningless and discarded -- and keeping it is what makes this a
// real gift wrap that GiftWrapEvent.create builds and unwrapOrNull opens.
assertTrue(first.verify(), "the wrap does not verify against its own key")
assertEquals(GiftWrapEvent.KIND, first.kind)
}
@Test
fun `the seal is what binds the words to their author`() {
val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob))
assertEquals(SealedRumorEvent.KIND, opened.seal.kind)
// The inbound path checks exactly this pair against the MLS sender identity. It
// is what replaces the MIP-03 pubkey check for kind:1059, so if the seal stops
// being signed by the sender, the carve-out becomes a hole.
assertEquals(alice.pubKey, opened.seal.pubKey)
assertTrue(opened.seal.verify(), "the seal's signature does not verify")
assertEquals(opened.seal.pubKey, opened.rumor.pubKey)
}
@Test
fun `a seal from somebody else is still opened, and is caught by its pubkey`() {
// open() decrypts; it does not adjudicate. Eve can seal her own rumor to Bob and
// wrap it, and Bob's key will open it -- what stops it being rendered as Alice's
// is the inbound check that the seal's pubkey is the MLS sender's identity. This
// test pins the half open() is responsible for: the pubkey it reports is Eve's.
val eveSigner = NostrSignerSync(eve)
val forged =
MarmotDirectMessage.wrap(
signer = eveSigner,
recipientPublicKey = bobPublicKey,
kind = ChatMessageEvent.KIND,
createdAt = at,
tags = arrayOf(PTag.assemble(bobPublicKey, null)),
content = "alice here, send the funds",
)
val opened = assertNotNull(MarmotDirectMessage.open(forged, bob))
assertEquals(eveSigner.pubKey, opened.seal.pubKey)
assertNotEquals(alice.pubKey, opened.seal.pubKey)
}
@Test
fun `the rumor id is the one the sender queued`() {
val tags = arrayOf(PTag.assemble(bobPublicKey, null))
// What the outbound queue computes before it has a signer, and what it later uses
// to find the chat message to link and broadcast. If these ever diverge the
// message is encrypted, stored, and silently never sent.
val queued =
EventHasher.hashId(
alice.pubKey,
at,
ChatMessageEvent.KIND,
tags,
text,
)
val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob))
assertEquals(queued, opened.rumor.id)
assertEquals(queued, MarmotDirectMessage.rumor(alice.pubKey, ChatMessageEvent.KIND, at, tags, text).id)
}
@Test
fun `no layer is fuzzed into the past`() {
val wrap = aliceWrapsForBob()
val opened = assertNotNull(MarmotDirectMessage.open(wrap, bob))
// NIP-59 defaults every one of these to randomWithTwoDays(). Inside MLS that only
// scatters the bystander line up to two days out of position in every other
// member's transcript, so all three carry the real message time.
assertEquals(at, wrap.createdAt)
assertEquals(at, opened.seal.createdAt)
assertEquals(at, opened.rumor.createdAt)
}
@Test
fun `the recipient is on the wrap where the group can see it`() {
val wrap = aliceWrapsForBob()
// Deliberate: it is what lets a bystander's transcript say who the message was
// for. Removing it would hide the recipient from the group at the cost of the
// named line -- see the decisions table in docs/marmot-direct-messages.md.
assertEquals(bobPublicKey, wrap.tags.firstTagValue("p"))
}
@Test
fun `garbage in the wrap does not become a message`() {
val notAWrap =
alice.signNormal<Event>(
createdAt = at,
kind = GiftWrapEvent.KIND,
tags = arrayOf(PTag.assemble(bobPublicKey, null)),
content = "not nip-44 at all",
)
assertNull(MarmotDirectMessage.open(notAWrap, bob))
}
}

View File

@@ -0,0 +1,80 @@
package press.mantra.compose.nostr
import press.mantra.compose.database.query.NostrEventFilterQuery
import press.mantra.compose.network.serialization.encodeToJsonString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Pins the only gift wrap filter that can come back with something we can read.
*
* Every clause here is one that was got wrong in production. Two call sites asked
* for `authors = [our pubkey]`, which cannot match a wrap signed by a throwaway
* key and so returned nothing at all, silently, for as long as it existed. A third
* asked for `p = [the peer]`, which returned other people's mail and crashed the
* save that tried to unseal it. Neither failure was visible from reading the
* filter, so the shape is asserted instead of trusted.
*/
class Nip17FiltersTest {
private val us = "a".repeat(64)
@Test
fun `it asks for wraps addressed to us`() {
assertEquals(mapOf("p" to listOf(us)), Nip17Filters.inbox(us).tags)
}
@Test
fun `it constrains no authors`() {
// GiftWrapEvent.create signs with a key it generates and drops, so the author
// of a wrap is a value nobody can predict -- least of all the sender's own
// pubkey. Any authors clause here silently matches zero events on every relay.
assertNull(Nip17Filters.inbox(us).authors)
}
@Test
fun `it carries no since cursor`() {
// A wrap is stamped up to two days earlier than it was sent, so a high-water
// mark taken from the newest wrap we hold skips mail that arrives behind it.
// Anything reintroducing `since` has to back-date by at least two days first.
assertNull(Nip17Filters.inbox(us).since)
assertNull(Nip17Filters.inbox(us).until)
}
@Test
fun `it asks for gift wraps and nothing else`() {
assertEquals(listOf(1059), Nip17Filters.inbox(us).kinds?.toList())
}
@Test
fun `two callers asking for the same inbox make one request`() {
// computeId hashes the encoded filter, so the chat room list and the chat
// message screen collapse into a single negentropy request only while both
// encode identically. Building the filter once is what holds that true; the
// wire shape is asserted so an added default cannot quietly split them.
assertEquals(Nip17Filters.inbox(us), Nip17Filters.inbox(us))
assertEquals(
"""{"kinds":[1059],"tags":{"p":["$us"]},"limit":50}""",
Nip17Filters.inbox(us).encodeToJsonString(),
)
}
@Test
fun `the local set it builds is the same set the relay is asked for`() {
// Negentropy reconciles our local set against the relay's: this filter goes out
// in NEG-OPEN, and the local side is built by running the same filter through
// NostrEventFilterQuery. A clause that survives one trip and not the other
// reports differences that are not real -- events re-downloaded forever, or
// pushed at a relay that excluded them on purpose. What matters here is that
// the local query reads the p tag and, like the wire filter, bounds no author:
// an authors clause would show up as `pubKey IN (?)`.
val query = NostrEventFilterQuery.build(Nip17Filters.inbox(us))
assertEquals(
"SELECT * FROM NostrEvent WHERE kind IN (?) AND (tags LIKE ? ESCAPE '\\') " +
"ORDER BY createdAt DESC, id DESC",
query.sql,
)
}
}

View File

@@ -0,0 +1,118 @@
package press.mantra.compose.nostr.nip30303
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
/**
* What the form collects has to reach the members deciding whether to sign it.
*
* An artifact proposal leaves the proposer's device as a kind, a tag array and
* a string, and everything a member is shown before signing -- and every row
* built afterwards -- is read back out of those. A field that does not survive
* the trip is not a visible failure: the artifact still appears, just without
* a url, or a source dialect, or a first version, on every device but the one
* that typed it.
*/
class ArtifactEventTest {
private val groupKey = "a".repeat(64)
private val dialectId = "b".repeat(64)
/** The event a member actually receives: bytes, with no template behind it. */
private fun readBack(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ArtifactEvent>) =
ArtifactEvent(
id = EventHasher.hashId(
pubKey = groupKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = groupKey,
createdAt = template.createdAt,
tags = template.tags,
content = template.content,
sig = "c".repeat(128),
)
private fun proposal(versionLabel: String = "1.0") = ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = versionLabel,
createdAt = 1_700_000_000L,
)
@Test
fun `every field the form collects survives the trip through the tags`() {
val artifact = readBack(proposal())
assertEquals("In Detention", artifact.content)
assertEquals("https://example.com/in-detention", artifact.url())
assertEquals("private", artifact.visibility())
assertEquals("cc", artifact.license())
assertEquals(dialectId, artifact.dialectId())
assertEquals("1.0", artifact.versionLabel())
}
@Test
fun `the proposal is the artifact's kind, so the signing screen can describe it`() {
// The session carries a kind and nothing else to go on. Get this wrong
// and members are asked to sign "event of kind 30300" -- a question
// nobody can answer.
assertEquals(ArtifactEvent.KIND, proposal().kind)
}
@Test
fun `an artifact declares one version, whatever an initializer adds`() {
// addUnique, not add: two labels would leave receivers deriving two
// different first versions depending on which one they read first.
val template = ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = "1.0",
) {
addUnique(ArtifactVersionMetadataTag.assemble("2.0"))
}
assertEquals(
1,
template.tags.count { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
)
}
@Test
fun `an artifact from before the label existed reads back as declaring none`() {
// Not an error: it is what every artifact submitted the old way looks
// like, and they have to keep parsing rather than failing to load.
val template = proposal()
val older = Event(
id = "d".repeat(64),
pubKey = groupKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags
.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
.toTypedArray(),
content = template.content,
sig = "",
)
val artifact = ArtifactEvent(
older.id, older.pubKey, older.createdAt, older.tags, older.content, older.sig
)
assertNull(artifact.versionLabel())
// Everything else still reads, so the artifact itself is unharmed.
assertEquals(dialectId, artifact.dialectId())
assertEquals("https://example.com/in-detention", artifact.url())
}
}