Files
mantra-kmp/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt
T
Kgothatso NgakoandClaude Opus 5 2fb8d6921b fix(projects): let a membership's foreign key violation out, so the sweep retries
A project's memberships are foreign keys into both the project and the version
they name, so a chronicle page carrying a membership whose project is in a
*later* page cannot apply it yet. `ChronicleManager.applyPage` handles exactly
this: it catches the violation, counts the payload `failed`, and `sweep` re-runs
every page until failures stop falling.

Phase 3 broke that for projects by catching the violation inside the apply arm.
`applyInnerEvent` then returned normally, `applyPage` counted the payload
applied, the sweep saw nothing left to do, and the membership was dropped --
silently, permanently, on the device a chronicle exists for.

The catch was added so that `FrostSigningManager.applySignedEvent` would not
report a succeeded session as failed. That reasoning was already satisfied:
`applySignedEvent` wraps the call in its own try/catch. And no sibling arm
catches -- a translation chapter naming an absent chapter throws today -- so the
catch made projects the only kind a chronicle could not retry. It is gone.

Pages are 128 events or 64 KiB, and projects sort last in APPLY_ORDER, so a
boundary falling between a project and its memberships is the ordinary case for
any room with a chapter's worth of chunks in it.

`pages arriving out of order still converge` already modelled this -- one page
per payload, delivered backwards, comparing every row count on both devices --
and passed anyway, because `rowCounts` did not look at the project tables. It
does now, which is what makes that test the one that would have caught this.
`ProjectApplyJvmTest` asserts the throw rather than the swallow, and adds the
retry in miniature: refused while the project is missing, accepted once it is
there, same event both times.

1404 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:05:28 +02:00

1056 lines
42 KiB
Kotlin

package press.mantra.compose.managers
import androidx.room3.Room
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.signers.EventTemplate
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 kotlin.test.AfterTest
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.Clock
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.MarmotKeyPackage
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.chronicle.ChronicleEvent
import press.mantra.compose.nostr.chronicle.ChronicleRequestEvent
import press.mantra.compose.nostr.chronicle.tags.ChronicleIdTag
import press.mantra.compose.nostr.chronicle.tags.ChroniclePageTag
import press.mantra.compose.nostr.chronicle.tags.ChronicleRecipientTag
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
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.ProjectArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ProjectEvent
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
/**
* Two devices, two databases: one that did the work and one that arrived after
* it, with the pages between them carried by hand.
*
* This is the claim the whole feature is: a member who holds no share, took part
* in no signing session and cannot decrypt a word of the room's history ends up
* with the same rows as everybody else, and gets there without trusting the
* member who sent them.
*
* The negative cases matter more than the positive one. Verification is what
* makes a chronicle safe to accept from anybody, so what has to be proved is that
* it refuses -- a forged payload beside honest ones, and a genuine key state
* replayed out of its epoch, which passes every signature check there is and is
* stopped by nothing but the allowlist.
*/
class ChronicleApplyJvmTest {
/** The member who did the work. */
private val sender: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
/** The member who arrived afterwards. No share, no sessions, no history. */
private val receiver: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
@AfterTest
fun closeDbs() {
sender.close()
receiver.close()
}
private val participants = 3
private val threshold = 2
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
/** A second group entirely: its own key, its own quorum, its own rooms. */
private val otherKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("2bada550000000000000000000000000000000000000000000000000000000b2")
),
nParticipants = participants,
threshold = threshold
)
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
private val chatRoomId = room.hex
private val oldMember = "8".repeat(64)
private val newMember = "9".repeat(64)
private fun groupSignature(material: KeyMaterial, eventId: String): String {
val cache = SharedKeyDerivation.derive(
thresholdPublicKey = material.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
).cache
val message = ByteVector(eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
secretShare = material.secretShares[signerId],
publicShare = material.publicShares[signerId],
tweakedThresholdPublicKey = cache.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 { material.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = cache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
signingSession.sign(
nonces[position].first,
material.secretShares[signerId],
signerId.toUInt()
).right!!
}
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
}
/** A template as a finished session leaves it, authored by [material]'s room. */
private fun signedBy(material: KeyMaterial, template: EventTemplate<*>): Event {
val author = SharedKeyDerivation.derive(
thresholdPublicKey = material.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
).hex
val id = EventHasher.hashId(
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
)
return Event(
id = id,
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = groupSignature(material, id)
)
}
private fun signed(template: EventTemplate<*>) = signedBy(keyMaterial, template)
private suspend fun seedRoom(db: MantraDatabase, user: String) {
val nostrEventId = "c".repeat(63) + if (user == newMember) "1" else "2"
db.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = user,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(Profile(publicKey = user, userName = "member", nostrEventId = nostrEventId))
db.chatRoomDao().upsert(
ChatRoom(
id = chatRoomId,
userPublicKey = user,
subject = "#Ekklesia",
description = null,
mlsGroupState = null,
)
)
}
private suspend fun apply(db: MantraDatabase, event: Event) {
ChatMessage.applyInnerEvent(
database = db,
groupId = chatRoomId,
event = event,
marmotGroupEventId = null,
marmotInnerEventId = null,
senderPublicKey = chatRoomId,
isUserMessage = false,
createdAt = Instant.fromEpochSeconds(event.createdAt)
)
}
/** The sender's room, with a full artifact and one translation scaffolded. */
private suspend fun seedSenderWork() {
seedRoom(sender, oldMember)
val dialect = signed(
DialectEvent.build(name = "isiZulu", country = "ZA", language = "zu", createdAt = 1_700_000_000L)
).also { apply(sender, it) }
val artifact = signed(
ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialect.id,
versionLabel = "1.0",
createdAt = 1_700_000_010L
)
).also { apply(sender, it) }
// The second item of the artifact's own signing batch, not a row derived
// on arrival -- which is what makes it chroniclable at all.
val version = signed(ArtifactVersionEvent.initialVersionOf(artifact).single())
.also { apply(sender, it) }
val chapter = signed(
ChapterEvent.build(
artifactVersionId = version.id,
name = "Chapter One",
originalText = "He was a man of parts. He had many.",
index = 0,
wordCount = 8,
characterCount = 35,
createdAt = 1_700_000_020L
)
).also { apply(sender, it) }
val chunk = signed(
ChunkEvent.build(
chapterId = chapter.id,
text = "He was a man of parts.",
index = 0,
wordCount = 6,
characterCount = 22,
createdAt = 1_700_000_030L
)
).also { apply(sender, it) }
apply(
sender,
signed(
ChunkEvent.build(
chapterId = chapter.id,
text = "He had many.",
index = 1,
wordCount = 3,
characterCount = 12,
createdAt = 1_700_000_031L
)
)
)
val translationVersion = signed(
TranslationArtifactVersionEvent.build(
artifactVersionId = version.id,
dialectId = dialect.id,
name = "isiZulu",
visibility = "private",
license = "cc",
createdAt = 1_700_000_040L
)
).also { apply(sender, it) }
val translationChapter = signed(
TranslationChapterEvent.build(
translationArtifactVersionId = translationVersion.id,
chapterId = chapter.id,
index = 0,
createdAt = 1_700_000_050L
)
).also { apply(sender, it) }
// The translated text. Group-signed now, so it can travel and be checked
// -- until it was, a chronicle handed a new member the whole structure and
// none of the prose.
apply(
sender,
signed(
TranslationChunkEvent.build(
translationChapterId = translationChapter.id,
chunkId = chunk.id,
index = 0,
text = "Wayeyindoda enezingxenye.",
createdAt = 1_700_000_060L
)
)
)
// What the work is for. Unlike everything above it, a project has to be
// *recorded* as well as applied to reach a chronicle: there is no
// `toProjectEvent`, so the deprecated rebuild cannot produce one, and
// the record is its only source. That is deliberate -- the rebuild
// serves rooms older than schema v13 and projects arrive at v21 -- and
// it is why `record` appears here and nowhere else in this seed.
val project = signed(
ProjectEvent.build(
name = "In Detention into isiZulu",
description = "The first chapter, with its back-translation.",
creatorPublicKey = oldMember,
ownerPublicKeys = setOf(chatRoomId),
createdAt = 1_700_000_070L,
)
).also { apply(sender, it) }
val sourceMembership = signed(
ProjectArtifactVersionEvent.membershipsOf(project, listOf(version.id)).single()
).also { apply(sender, it) }
val translationMembership = signed(
ProjectTranslationArtifactVersionEvent
.membershipsOf(project, listOf(translationVersion.id))
.single()
).also { apply(sender, it) }
sender.groupSignedEventDao().recordAll(
listOf(project, sourceMembership, translationMembership).map {
GroupSignedEvent.fromEvent(
event = it,
chatRoomId = chatRoomId,
derivationPath = SharedKeyDerivation.formatPath(),
frostSigningSessionId = "session-1",
)
}
)
}
/**
* A chronicle page as it reaches a device: stored as an inner event first,
* exactly as the inbound path stores every payload it decrypts before
* dispatching on kind, then handed to the manager.
*/
private suspend fun deliver(
template: EventTemplate<ChronicleEvent>,
to: String = newMember,
from: String = oldMember,
): ChronicleManager.Outcome {
val page = Event(
id = EventHasher.hashId(
pubKey = from,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = from,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
// A chronicle page is a member's own rumor. What carries the group's
// word is each payload's signature, not the envelope's.
content = template.content,
sig = ""
)
receiver.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = page.id,
publicKey = page.pubKey,
kind = page.kind,
createdAt = Instant.fromEpochSeconds(page.createdAt),
tags = page.tags,
content = page.content,
chatRoomId = chatRoomId,
)
)
return ChronicleManager.receive(receiver, chatRoomId, to, page)
}
private suspend fun senderChronicle() = ChronicleManager.assemble(sender, chatRoomId, newMember)
private suspend fun rowCounts(db: MantraDatabase): Map<String, Int> {
val dialects = db.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
val artifacts = db.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId)
val versions = artifacts.flatMap {
db.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(it.id)
}
val chapters = versions.flatMap { db.mantraChapterDao().getChaptersByArtifactVersionId(it.id) }
val chunks = chapters.flatMap { db.mantraChunkDao().getChunksByChapterId(it.id) }
val translationVersions = versions.flatMap {
db.mantraTranslationArtifactVersionDao().getTranslationsByArtifactVersionId(it.id)
}
val translationChapters = translationVersions.flatMap {
db.mantraTranslationChapterDao()
.getTranslationChaptersByTranslationArtifactVersionId(it.id)
}
val translationChunks = translationChapters.flatMap {
db.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(it.id)
}
// The projects, and what each one holds.
//
// Counted here rather than in a test of their own, because the test that
// matters most for them is `pages arriving out of order still converge`
// -- and that test compares exactly this map. A membership is a foreign
// key into both a project and a version, so delivering backwards is the
// scenario where one gets refused and has to be retried, and this map not
// looking at the project tables is why that test once passed against an
// apply arm that swallowed the refusal and lost the row.
val projects = db.mantraProjectDao().getProjectsByChatRoomId(chatRoomId)
val projectSources = projects.flatMap {
db.mantraProjectArtifactVersionDao().getByProjectId(it.id)
}
val projectTranslations = projects.flatMap {
db.mantraProjectTranslationArtifactVersionDao().getByProjectId(it.id)
}
return mapOf(
"dialects" to dialects.size,
"artifacts" to artifacts.size,
"versions" to versions.size,
"chapters" to chapters.size,
"chunks" to chunks.size,
"translationVersions" to translationVersions.size,
"translationChapters" to translationChapters.size,
"translationChunks" to translationChunks.size,
"projects" to projects.size,
"projectSources" to projectSources.size,
"projectTranslations" to projectTranslations.size,
)
}
/**
* Every chronicled row as (id, author, signature), sorted.
*
* Counting is not the claim. Two databases can hold the same number of
* artifacts and disagree about every one of them, and a rebuild that lost the
* group's signature -- or re-authored a row as whoever sent it -- would pass a
* count and fail the only thing the chronicle is for.
*/
private suspend fun rowFingerprints(db: MantraDatabase): List<Triple<String, String, String>> {
val artifacts = db.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId)
val versions = artifacts.flatMap {
db.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(it.id)
}
val chapters = versions.flatMap { db.mantraChapterDao().getChaptersByArtifactVersionId(it.id) }
val translationVersions = versions.flatMap {
db.mantraTranslationArtifactVersionDao().getTranslationsByArtifactVersionId(it.id)
}
return buildList {
db.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
artifacts.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
versions.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
chapters.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
chapters.flatMap { db.mantraChunkDao().getChunksByChapterId(it.id) }
.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
translationVersions.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
val translationChapters = translationVersions.flatMap {
db.mantraTranslationChapterDao()
.getTranslationChaptersByTranslationArtifactVersionId(it.id)
}
translationChapters.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
translationChapters
.flatMap { db.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(it.id) }
.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
}.sortedBy { it.first }
}
@Test
fun `a member who was never there ends up holding what the group signed`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
assertEquals(
mapOf(
"dialects" to 0, "artifacts" to 0, "versions" to 0, "chapters" to 0,
"chunks" to 0, "translationVersions" to 0, "translationChapters" to 0,
"translationChunks" to 0, "projects" to 0, "projectSources" to 0,
"projectTranslations" to 0,
),
rowCounts(receiver),
"the new member starts with nothing, which is the whole problem"
)
senderChronicle().forEach { deliver(it) }
assertEquals(rowCounts(sender), rowCounts(receiver))
// Not merely the same shape: the same rows, each with the group's own
// signature on it. A chronicle that produced look-alike rows authored by
// the member who sent them would pass a count and fail the only thing
// this is for.
val fingerprints = rowFingerprints(receiver)
assertEquals(rowFingerprints(sender), fingerprints)
// And every one of them is the room's own work, with no exception left.
// The artifact version used to be one -- derived on arrival, so authored
// by the group and carrying no signature to show for it -- and is signed
// in the artifact's own batch now, which is what let it into the chronicle.
fingerprints.forEach { (id, author, signature) ->
assertEquals(chatRoomId, author, "row $id is not authored by the room")
assertTrue(signature.isNotBlank(), "row $id came across without a signature")
}
}
/**
* The member who was handed history can hand it on.
*
* Rows are a reading of what the group signed; the events are the thing
* itself, and until they are kept the recipient of a chronicle is a dead end
* -- holding the work, unable to prove any of it, and unable to build a page
* for the next member to arrive. The path is the room's, read from the key
* state when the recipient has one.
*/
@Test
fun `the chronicle leaves the receiver holding the events, not only the rows`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
senderChronicle().forEach { deliver(it) }
val signed = receiver.groupSignedEventDao().getByChatRoomId(chatRoomId)
assertEquals(
senderChronicle()
.flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) }
.map { it.id }
.toSet(),
signed.map { it.id }.toSet(),
"every payload that applied should be on file as the group signed it"
)
assertTrue(signed.isNotEmpty())
signed.forEach { event ->
assertEquals(chatRoomId, event.publicKey, "${event.id} is not authored by the room")
assertTrue(event.verifies(), "${event.id} does not read back as the room's")
// Nothing on this device ran a session for any of it.
assertNull(event.frostSigningSessionId)
}
// The rows the payloads became point back at them, which is the only
// provenance a group-signed row has: no group event, no inner event.
val dialects = receiver.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
assertTrue(dialects.isNotEmpty())
dialects.forEach { assertEquals(it.id, it.groupSignedEventId) }
}
@Test
fun `a chronicle files no chat lines`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
senderChronicle().forEach { deliver(it) }
assertEquals(
emptyList(),
receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId)
.map { it.chatMessage.messageType }
.filterNot { it in ChatMessage.CHRONICLE_TYPES },
"the chronicle restores the work; the conversation is forward secret and stays gone"
)
}
@Test
fun `pages arriving out of order still converge`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
// One page per payload, delivered backwards -- a translation chapter
// before the chapter it names, chunks before their chapter, the artifact
// last. Relays give no ordering guarantee, so this is the ordinary case
// rather than the adversarial one.
val payloads = senderChronicle()
.flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) }
val chronicleId = "e".repeat(64)
val outcomes = payloads.reversed().mapIndexed { index, payload ->
deliver(
ChronicleEvent.build(
payloads = listOf(payload),
chronicleId = chronicleId,
index = index,
count = payloads.size,
recipient = newMember,
createdAt = 1_700_000_100L + index,
)
)
}
// Without this the test could pass for the wrong reason -- if nothing
// ever failed to apply, the pages were not really out of order and the
// sweep was never the thing that saved them.
assertTrue(
outcomes.any { it.failed > 0 },
"delivering backwards should have left payloads waiting on rows they depend on"
)
assertEquals(
0,
outcomes.last().failed,
"the last page completes the chronicle, so the pass it triggers should leave nothing"
)
assertEquals(rowCounts(sender), rowCounts(receiver))
}
@Test
fun `one forged payload costs itself and nothing else`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
val honest = signed(
DialectEvent.build(name = "isiZulu", country = "ZA", language = "zu", createdAt = 1_700_000_000L)
)
// Four ways to be a dishonest member, in one page beside one honest
// dialect. Every one of them is well-formed and would file a row without
// the check.
val forgeries = listOf(
// The room's id as author -- everybody knows it -- and a made-up
// signature.
Event(
id = EventHasher.hashId(
pubKey = chatRoomId, createdAt = 1_700_000_001L, kind = DialectEvent.KIND,
tags = honest.tags, content = "forged"
),
pubKey = chatRoomId, createdAt = 1_700_000_001L, kind = DialectEvent.KIND,
tags = honest.tags, content = "forged", sig = "9".repeat(128)
),
// A real quorum, of another group.
signedBy(
otherKeyMaterial,
DialectEvent.build(name = "seSotho", country = "ZA", language = "st", createdAt = 1_700_000_002L)
),
// Edited after the group signed it.
Event(
id = honest.id, pubKey = honest.pubKey, createdAt = honest.createdAt,
kind = honest.kind,
tags = arrayOf(arrayOf("alt", "Dialect"), arrayOf("name", "tampered")),
content = honest.content, sig = honest.sig
),
// A member's own rumor, which is what everything on the wire looks
// like today.
Event(
id = EventHasher.hashId(
pubKey = oldMember, createdAt = 1_700_000_003L, kind = DialectEvent.KIND,
tags = honest.tags, content = "unsigned"
),
pubKey = oldMember, createdAt = 1_700_000_003L, kind = DialectEvent.KIND,
tags = honest.tags, content = "unsigned", sig = ""
),
)
val outcome = deliver(
ChronicleEvent.build(
payloads = forgeries + honest,
chronicleId = "f".repeat(64),
index = 0,
count = 1,
recipient = newMember,
createdAt = 1_700_000_100L,
)
)
assertEquals(4, outcome.skipped)
assertEquals(1, outcome.applied)
val dialects = receiver.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
assertEquals(1, dialects.size, "exactly the honest one")
assertEquals(honest.id, dialects.single().id)
// And nothing forged was kept as an event either. The record is what a
// later chronicle is built from, so a forgery filed here would be one this
// member goes on to hand to everybody else.
assertEquals(
listOf(honest.id),
receiver.groupSignedEventDao().getByChatRoomId(chatRoomId).map { it.id }
)
}
@Test
fun `a key state the room really signed cannot be replayed through a chronicle`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
// Signed by the room, verifying perfectly, and refused: the allowlist is
// the only thing between "the group signed this once" and "any member may
// re-point what this room signs with, at any time, forever".
val keyState = signed(
EventTemplate<Event>(
createdAt = 1_700_000_060L,
kind = GroupKeyStateEvent.KIND,
tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "somebody-elses-ceremony",
),
content = keyMaterial.thresholdPublicKey.value.toHex(),
)
)
assertTrue(
GroupKeyStateEvent.isSignedByRoom(keyState, chatRoomId),
"the point of this test is that verification passes"
)
// Built by hand: ChronicleEvent.build refuses this kind, which is the
// outbound half of the same rule. Only a hand-rolled page gets this far.
val page = EventTemplate<ChronicleEvent>(
createdAt = 1_700_000_100L,
kind = ChronicleEvent.KIND,
tags = arrayOf(
arrayOf("alt", ChronicleEvent.ALT_DESCRIPTION),
ChronicleIdTag.assemble("a".repeat(64)),
ChroniclePageTag.assemble(0, 1),
ChronicleRecipientTag.assemble(newMember),
),
content = ChronicleEvent.encodePage(listOf(keyState)),
)
val outcome = deliver(page)
assertEquals(1, outcome.skipped)
assertEquals(0, outcome.applied)
assertNull(receiver.groupKeyStateDao().getByChatRoomId(chatRoomId))
}
@Test
fun `a member the chronicle is not addressed to applies none of it`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
val outcome = senderChronicle().map { deliver(it, to = "7".repeat(64)) }
assertEquals(0, outcome.sumOf { it.applied })
assertEquals(
mapOf(
"dialects" to 0, "artifacts" to 0, "versions" to 0, "chapters" to 0,
"chunks" to 0, "translationVersions" to 0, "translationChapters" to 0,
"translationChunks" to 0, "projects" to 0, "projectSources" to 0,
"projectTranslations" to 0,
),
rowCounts(receiver),
"a bystander already holds the work; re-applying would only rewrite its provenance"
)
}
@Test
fun `applying the same chronicle twice changes nothing the second time`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
val chronicle = senderChronicle()
chronicle.forEach { deliver(it) }
val afterFirst = rowCounts(receiver)
chronicle.forEach { deliver(it) }
assertEquals(afterFirst, rowCounts(receiver))
assertEquals(
emptyList(),
receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId)
.map { it.chatMessage.messageType }
.filterNot { it in ChatMessage.CHRONICLE_TYPES },
)
}
// ---- Asking for one --------------------------------------------------
private suspend fun queued(db: MantraDatabase, kind: Int) = db.marmotInnerEventDao()
.getByChatRoomAndKinds(chatRoomId, listOf(kind))
.filter { it.marmotGroupEventId == null }
@Test
fun `a device holding none of the room's work asks for it`() = runBlocking {
seedRoom(receiver, newMember)
assertTrue(ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember))
// Queued with no chat line, the way a signing message travels. Broadcast
// does not depend on one, and a row of envelopes in the transcript is not
// what a chronicle should leave behind.
assertEquals(1, queued(receiver, ChronicleRequestEvent.KIND).size)
// One line saying so, and nothing else. Asking is a thing this device did
// and the room would otherwise sit empty with no explanation.
assertEquals(
listOf(ChatMessage.TYPE_CHRONICLE_REQUESTED),
receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId)
.map { it.chatMessage.messageType }
)
}
@Test
fun `a device does not ask twice while an answer is in flight`() = runBlocking {
seedRoom(receiver, newMember)
assertTrue(ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember))
assertFalse(ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember))
assertEquals(1, queued(receiver, ChronicleRequestEvent.KIND).size)
}
@Test
fun `a device holding the work does not ask for it`() = runBlocking {
seedSenderWork()
assertFalse(ChronicleManager.requestIfEmpty(sender, chatRoomId, oldMember))
assertEquals(0, queued(sender, ChronicleRequestEvent.KIND).size)
}
@Test
fun `an answered request lets the room ask again`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember)
assertNotNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.chronicleRequestedAt)
senderChronicle().forEach { deliver(it) }
// Cleared on anything applied rather than on the sender claiming the
// chronicle was complete. A page count is the sender's word about the
// transfer, not about the group's record, so a member who left work out
// must not get the last word on whether to ask again.
assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.chronicleRequestedAt)
}
@Test
fun `answering a request queues the chronicle for whoever asked`() = runBlocking {
seedSenderWork()
val pages = ChronicleManager.sendTo(sender, chatRoomId, oldMember, recipient = newMember)
assertEquals(1, pages)
val queuedPage = queued(sender, ChronicleEvent.KIND).single()
assertEquals(oldMember, queuedPage.publicKey, "the page is the answering member's own rumor")
val page = ChronicleEvent(
id = queuedPage.id,
pubKey = queuedPage.publicKey,
createdAt = queuedPage.createdAt.epochSeconds,
tags = queuedPage.tags,
content = queuedPage.content,
sig = "",
)
assertEquals(newMember, page.recipient())
assertEquals(ChronicleEvent.CHRONICLABLE_KINDS, page.payloads()?.map { it.kind }?.toSet())
// One line about the chronicle, and no line per page.
assertEquals(
listOf(ChatMessage.TYPE_CHRONICLE_SENT),
sender.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId)
.map { it.chatMessage.messageType }
)
}
@Test
fun `a member with nothing signed answers nothing rather than an empty chronicle`() = runBlocking {
seedRoom(receiver, newMember)
assertEquals(0, ChronicleManager.sendTo(receiver, chatRoomId, newMember, recipient = oldMember))
assertEquals(0, queued(receiver, ChronicleEvent.KIND).size)
}
@Test
fun `a member does not answer their own request`() = runBlocking {
seedSenderWork()
assertEquals(0, ChronicleManager.sendTo(sender, chatRoomId, oldMember, recipient = oldMember))
assertEquals(0, queued(sender, ChronicleEvent.KIND).size)
}
@Test
fun `asking and answering completes the round trip`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
// The joiner asks.
assertTrue(ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember))
// A member holding the work answers it, addressed to whoever asked.
ChronicleManager.sendTo(sender, chatRoomId, oldMember, recipient = newMember)
// The pages reach the joiner, who applies them.
queued(sender, ChronicleEvent.KIND).forEach { queuedPage ->
deliver(
EventTemplate(
createdAt = queuedPage.createdAt.epochSeconds,
kind = queuedPage.kind,
tags = queuedPage.tags,
content = queuedPage.content,
)
)
}
assertEquals(rowCounts(sender), rowCounts(receiver))
assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.chronicleRequestedAt)
}
// ---- The push behind a Welcome ---------------------------------------
/**
* The invitee's key package, which is how `deliveryWelcome` learns who it is
* addressing. The bytes are never decoded on this path.
*/
private suspend fun seedInviteeKeyPackage(): String {
val keyPackageId = "1".repeat(64)
val nostrEventId = "2".repeat(64)
sender.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = newMember,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
sender.profileDao().upsert(
Profile(publicKey = newMember, userName = "invitee", nostrEventId = nostrEventId)
)
sender.marmotKeyPackageDao().upsert(
MarmotKeyPackage(
id = keyPackageId,
publicKey = newMember,
tlsEncodedMarmotKeyPackage = ByteArray(0),
)
)
return keyPackageId
}
@Test
fun `inviting a member offers them the group's work alongside the welcome`() = runBlocking {
seedSenderWork()
sender.marmotOutboundDao().deliveryWelcome(
nostrGroupId = chatRoomId,
userPublicKey = oldMember,
welcomeBytes = ByteArray(8),
peerKeyPackageEventId = seedInviteeKeyPackage(),
relays = listOf("wss://relay.example.com"),
createdAt = Clock.System.now(),
)
val page = queued(sender, ChronicleEvent.KIND).single()
assertEquals(
newMember,
ChronicleEvent(
id = page.id,
pubKey = page.publicKey,
createdAt = page.createdAt.epochSeconds,
tags = page.tags,
content = page.content,
sig = "",
).recipient()
)
}
@Test
fun `inviting into a room with nothing signed offers nothing, and still invites`() = runBlocking {
seedRoom(sender, oldMember)
sender.marmotOutboundDao().deliveryWelcome(
nostrGroupId = chatRoomId,
userPublicKey = oldMember,
welcomeBytes = ByteArray(8),
peerKeyPackageEventId = seedInviteeKeyPackage(),
relays = listOf("wss://relay.example.com"),
createdAt = Clock.System.now(),
)
assertEquals(0, queued(sender, ChronicleEvent.KIND).size)
// The Welcome itself still went out. A push that has nothing to say is
// not a failed invite, and nothing about the chronicle may report one:
// the invitee asks for themselves on their first open regardless.
assertEquals(
1,
sender.giftWrapPayloadDao()
.getByChatRoomAndKinds(chatRoomId, listOf(WelcomeEvent.KIND))
.size
)
}
@Test
fun `an answered catch-up leaves one line, whatever it took to deliver`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember)
// Delivered a page at a time and out of order, so the sweep runs several
// times over several pages. The transcript is not a log of that.
val payloads = senderChronicle()
.flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) }
payloads.reversed().forEachIndexed { index, payload ->
deliver(
ChronicleEvent.build(
payloads = listOf(payload),
chronicleId = "e".repeat(64),
index = index,
count = payloads.size,
recipient = newMember,
createdAt = 1_700_000_100L + index,
)
)
}
// Newest first, which is the transcript's order: the answer is the line above
// the composer and the ask sits over it. Both land in the same second, so what
// decides between them is `ChatMessageDao`'s `id DESC` rather than the sorter --
// without it this read whichever way SQLite happened to hand the tie back.
assertEquals(
listOf(ChatMessage.TYPE_CHRONICLE_RECEIVED, ChatMessage.TYPE_CHRONICLE_REQUESTED),
receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId)
.map { it.chatMessage.messageType }
)
}
@Test
fun `a push nobody asked for says nothing in the transcript`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
// The invite-time push lands before the member has opened the room, so
// "caught up on work you have not seen yet" is a line about nothing.
senderChronicle().forEach { deliver(it) }
assertEquals(
emptyList(),
receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId)
.map { it.chatMessage.messageType }
)
}
}