Merge branch 'mantra' into claude/nostr-event-save-issue-6e9467
This commit is contained in:
@@ -435,17 +435,13 @@ abstract class MarmotOutboundDao(
|
||||
|
||||
// Save commitResult... in case we need to broadcast welcomeEvent after relay acknowledgement...
|
||||
database.marmotCommitResultDao().upsert(
|
||||
MarmotCommitResult(
|
||||
id = commitEvent.id,
|
||||
isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation,
|
||||
MarmotCommitResult.from(
|
||||
commitEventId = commitEvent.id,
|
||||
commitResult = commitResult,
|
||||
chatRoomId = nostrGroupId,
|
||||
commitBytes = commitResult.commitBytes,
|
||||
preCommitExporterSecret = commitResult.preCommitExporterSecret,
|
||||
welcomeBytes = commitResult.welcomeBytes,
|
||||
framedCommitBytes = commitResult.preCommitExporterSecret,
|
||||
groupInfoBytes = commitResult.groupInfoBytes,
|
||||
userPublicKey = userPublicKey,
|
||||
peerKeyPackageEventId = peerKeyPackage.id,
|
||||
isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation,
|
||||
createdAt = Instant.fromEpochSeconds(commitEvent.createdAt)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.managers.MlsGroupCache
|
||||
import press.mantra.compose.managers.MarmotInboundManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -388,9 +389,21 @@ abstract class NostrDao(
|
||||
val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)
|
||||
|
||||
if (localChatRoom != null) {
|
||||
val mlsGroup = localChatRoom.chatRoom.toMlsGroup()
|
||||
|
||||
if (mlsGroup != null) {
|
||||
// Through the cache rather than rebuilt here, so the secret
|
||||
// tree's skipped-generation keys survive from one message to
|
||||
// the next. Two events published in the same instant arrive in
|
||||
// whatever order the relay feels like, and rebuilding between
|
||||
// them loses the earlier one for good -- see MlsGroupCache.
|
||||
val handled = MlsGroupCache.withGroup(
|
||||
chatRoomId = chatRoomId,
|
||||
storedStateHex = localChatRoom.chatRoom.mlsGroupState,
|
||||
build = { localChatRoom.chatRoom.toMlsGroup() },
|
||||
save = { stateHex ->
|
||||
database.chatRoomDao().upsert(
|
||||
localChatRoom.chatRoom.copy(mlsGroupState = stateHex)
|
||||
)
|
||||
}
|
||||
) { mlsGroup ->
|
||||
val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) ->
|
||||
|
||||
val pubkey = when (val cred = leafNode.credential) {
|
||||
@@ -437,12 +450,6 @@ abstract class NostrDao(
|
||||
}
|
||||
}
|
||||
|
||||
// Save the mls chatRoom state...
|
||||
database.chatRoomDao().upsert(
|
||||
localChatRoom.chatRoom.copy(
|
||||
mlsGroupState = mlsGroup.saveState().encodeTls().toHex()
|
||||
)
|
||||
)
|
||||
ChatMessage.fromGroupEventResult(
|
||||
database = database,
|
||||
activeKeyPair = activeKeyPair,
|
||||
@@ -477,7 +484,11 @@ abstract class NostrDao(
|
||||
} else {
|
||||
throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}")
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Null means the room has no usable group state, which is what
|
||||
// a failed toMlsGroup() meant before the cache existed.
|
||||
if (handled == null) {
|
||||
throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for $nostrEvent")
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ import press.mantra.compose.database.model.traits.SoftDeletableEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.database.model.traits.UserViewableEntity
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -72,6 +73,40 @@ data class MarmotCommitResult( // TODO: Rename this to GiftWrapPayload...
|
||||
companion object {
|
||||
const val TAG = "MarmotCommitResult"
|
||||
|
||||
/**
|
||||
* The persisted record of a commit, built from the [CommitResult] that produced it.
|
||||
*
|
||||
* The five payload fields are carried over from quartz verbatim -- same names, same
|
||||
* order, same `ByteArray` type on both sides of the copy -- so a value taken from the
|
||||
* wrong field of the right object typechecks and reaches the database unnoticed.
|
||||
* `framedCommitBytes = commitResult.preCommitExporterSecret` survived exactly that way,
|
||||
* storing the group's pre-commit exporter secret in the column documented to hold a
|
||||
* broadcastable MLS envelope.
|
||||
*
|
||||
* Mapping here rather than at the call site means it is written once, in declaration
|
||||
* order, and pinned by MarmotCommitResultMappingTest.
|
||||
*/
|
||||
fun from(
|
||||
commitEventId: HexKey,
|
||||
commitResult: CommitResult,
|
||||
chatRoomId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
peerKeyPackageEventId: HexKey,
|
||||
isOneMemberInitialGroupCreation: Boolean,
|
||||
createdAt: Instant,
|
||||
): MarmotCommitResult = MarmotCommitResult(
|
||||
id = commitEventId,
|
||||
userPublicKey = userPublicKey,
|
||||
peerKeyPackageEventId = peerKeyPackageEventId,
|
||||
chatRoomId = chatRoomId,
|
||||
isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation,
|
||||
commitBytes = commitResult.commitBytes,
|
||||
welcomeBytes = commitResult.welcomeBytes,
|
||||
groupInfoBytes = commitResult.groupInfoBytes,
|
||||
framedCommitBytes = commitResult.framedCommitBytes,
|
||||
preCommitExporterSecret = commitResult.preCommitExporterSecret,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Keeps a room's [MlsGroup] alive between messages instead of rebuilding it
|
||||
* from the stored state every time.
|
||||
*
|
||||
* ### The bug this exists for
|
||||
*
|
||||
* MLS is specified to tolerate out-of-order delivery within an epoch: a
|
||||
* receiver that gets generation N+1 before N derives and caches the key for N
|
||||
* so the older message can still be read when it turns up. Quartz's
|
||||
* `SecretTree` does exactly that, in a private `skippedKeys` map.
|
||||
*
|
||||
* `SecretTree.exportSenderStates()` does not include that map, so
|
||||
* `MlsGroup.saveState()` does not carry it. Rebuilding the group from stored
|
||||
* state therefore throws the skipped keys away, and a message for a generation
|
||||
* the ratchet has already passed fails
|
||||
* `require(generation >= state.applicationGeneration)` and is dropped. There is
|
||||
* no recovering it afterwards: the key is gone and the sender will not resend.
|
||||
*
|
||||
* Nostr relays offer no ordering whatsoever, so this is not an edge case. Two
|
||||
* events published in the same second race, and exactly one survives — which is
|
||||
* how a signing session's proposal was lost while the nonce sent immediately
|
||||
* behind it arrived fine.
|
||||
*
|
||||
* ### What this fixes, and what it does not
|
||||
*
|
||||
* Holding the instance means `skippedKeys` survives for as long as the process
|
||||
* does and nothing else writes the room's state. That covers the case that
|
||||
* actually bites — a burst of messages arriving in one sync — because they are
|
||||
* decrypted one after another against the same tree.
|
||||
*
|
||||
* It does not survive a restart, and it does not survive another writer, so
|
||||
* reordering across app launches still loses messages. The real fix is for
|
||||
* `exportSenderStates` to carry the skipped keys; see
|
||||
* `docs/mls-skipped-keys.md`.
|
||||
*
|
||||
* ### Staleness
|
||||
*
|
||||
* The group is only reused when the stored state is still exactly what this
|
||||
* cache last wrote. Anything else that saves a room's state — sending a message
|
||||
* advances the sender ratchet and saves, so does adding a member — changes the
|
||||
* hex, and the next read rebuilds rather than carrying on from a group that has
|
||||
* been overtaken. Losing the skipped keys there is the same behaviour as
|
||||
* before this existed, so the fallback is never worse than not caching.
|
||||
*/
|
||||
object MlsGroupCache {
|
||||
private val cache = LiveInstanceCache<MlsGroup> { it.saveState().encodeTls().toHex() }
|
||||
|
||||
/**
|
||||
* Runs [block] against the room's live group, then stores whatever state it
|
||||
* left behind.
|
||||
*
|
||||
* [storedStateHex] is the room's state as the database currently has it, and
|
||||
* [build] turns it into a group. [save] is handed the state to persist.
|
||||
*
|
||||
* Returns null without calling [block] when the room has no usable group
|
||||
* state, which is the same thing a failed `toMlsGroup()` meant before.
|
||||
*/
|
||||
suspend fun <T> withGroup(
|
||||
chatRoomId: String,
|
||||
storedStateHex: String?,
|
||||
build: () -> MlsGroup?,
|
||||
save: suspend (String) -> Unit,
|
||||
block: suspend (MlsGroup) -> T,
|
||||
): T? = cache.withInstance(
|
||||
key = chatRoomId,
|
||||
storedState = storedStateHex,
|
||||
build = build,
|
||||
save = save,
|
||||
block = block
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One live instance per key, reused only while the stored state is still the one
|
||||
* this cache last wrote.
|
||||
*
|
||||
* Split out from [MlsGroupCache] so the decision it makes can be tested without
|
||||
* standing up an MLS group. That decision is the whole safety argument: reuse
|
||||
* when nothing else has written, rebuild when something has, and never carry on
|
||||
* with an instance whose last use failed part-way through.
|
||||
*/
|
||||
internal class LiveInstanceCache<T : Any>(
|
||||
/** The persisted form of an instance, for spotting another writer. */
|
||||
private val stateOf: (T) -> String,
|
||||
) {
|
||||
private val logger = Logger.withTag("LiveInstanceCache")
|
||||
|
||||
private class Entry<T>(val instance: T, val state: String)
|
||||
|
||||
private val entries = mutableMapOf<String, Entry<T>>()
|
||||
|
||||
/**
|
||||
* Serialises use of one key's instance.
|
||||
*
|
||||
* The instance is mutable and [block] advances it, so two callers running at
|
||||
* once would corrupt it. One lock per key rather than one overall, so a busy
|
||||
* key cannot hold up a quiet one.
|
||||
*
|
||||
* Held across [block], which may touch the database. Safe here because a
|
||||
* caller only ever takes this lock while it is already running -- it never
|
||||
* waits on a resource the holder is itself waiting for.
|
||||
*/
|
||||
private val locks = mutableMapOf<String, Mutex>()
|
||||
private val locksGuard = Mutex()
|
||||
|
||||
private suspend fun lockFor(key: String): Mutex =
|
||||
locksGuard.withLock { locks.getOrPut(key) { Mutex() } }
|
||||
|
||||
suspend fun <R> withInstance(
|
||||
key: String,
|
||||
storedState: String?,
|
||||
build: () -> T?,
|
||||
save: suspend (String) -> Unit,
|
||||
block: suspend (T) -> R,
|
||||
): R? = lockFor(key).withLock {
|
||||
val cached = entries[key]
|
||||
|
||||
val instance = if (cached != null && cached.state == storedState) {
|
||||
cached.instance
|
||||
} else {
|
||||
if (cached != null) {
|
||||
logger.d("$key was written elsewhere; rebuilding")
|
||||
}
|
||||
// Dropped before the block runs, so a build that fails does not leave
|
||||
// the old instance behind to be picked up by the next caller.
|
||||
entries.remove(key)
|
||||
build() ?: return@withLock null
|
||||
}
|
||||
|
||||
// Deliberately not in a finally: an instance whose use threw part-way is
|
||||
// in an unknown state, and the next caller should rebuild from whatever
|
||||
// was last persisted rather than carry on with it.
|
||||
val result = block(instance)
|
||||
|
||||
val state = stateOf(instance)
|
||||
save(state)
|
||||
entries[key] = Entry(instance = instance, state = state)
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/** How many instances are held. For tests. */
|
||||
internal fun size(): Int = entries.size
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user