Merge branch 'mantra' into claude/nostr-event-save-issue-6e9467

This commit is contained in:
Kgothatso Ngako
2026-09-05 23:30:28 +02:00
9 changed files with 918 additions and 18 deletions

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,174 @@
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
)
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,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)
}
}