Merge branch 'mantra' into claude/distracted-franklin-e95ba4
This commit is contained in:
@@ -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,278 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
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 fr.acinq.secp256k1.Hex
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
|
||||
/**
|
||||
* The two rounds a signing session runs, against real FROST.
|
||||
*
|
||||
* `FrostSigningManager` spreads these steps across arriving messages, several
|
||||
* devices and a database, none of which a unit test can stand up. What it can
|
||||
* do is run the same calls in the same order with the same arguments and check
|
||||
* that what comes out is a signature nostr will accept — which is the part
|
||||
* that was written from reading the library rather than from a working example,
|
||||
* and so the part most likely to be subtly wrong.
|
||||
*
|
||||
* A signature that verifies is the whole contract: if these calls are wired up
|
||||
* incorrectly the aggregate simply fails to verify, silently, on every device.
|
||||
*/
|
||||
class FrostSigningRoundTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
/** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */
|
||||
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()
|
||||
|
||||
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
|
||||
private fun eventId(content: String): String = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 1,
|
||||
tags = arrayOf(),
|
||||
content = content
|
||||
)
|
||||
|
||||
/**
|
||||
* One signer's half of the protocol, in the manager's order: regenerate the
|
||||
* nonce from stored randomness, then sign once the set is known.
|
||||
*/
|
||||
private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair<SecretNonce, IndividualNonce> =
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32(random),
|
||||
secretShare = keyMaterial.secretShares[signerId],
|
||||
publicShare = keyMaterial.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
|
||||
private fun sessionFor(signerIds: List<Int>, nonces: List<IndividualNonce>, message: ByteVector): Session {
|
||||
val aggregated = IndividualNonce.aggregate(nonces).right!!
|
||||
|
||||
return Session.create(
|
||||
aggregatedNonce = aggregated,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
|
||||
nParticipants = participants,
|
||||
threshold = threshold,
|
||||
tweakCache = tweakCache,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a threshold of signers produces a signature nostr accepts`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
// Two of the three sign, which is the point of a 2-of-3 key.
|
||||
val signerIds = listOf(0, 1)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") }
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"the aggregated signature must verify against the group's x-only key"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different pair of signers signs the same event just as well`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
// Whoever happens to be available. The coordinator picks; the signature
|
||||
// that comes out must not depend on which t it picked.
|
||||
val signerIds = listOf(1, 2)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") }
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signature over one event does not verify against another`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
val signerIds = listOf(0, 1)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") }
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertFalse(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = eventId("the group agrees to something else").hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"a signature is over one event id and must not carry to another"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `regenerating a nonce from the same seed and message gives the same nonce`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
val random = "d".repeat(63) + "1"
|
||||
|
||||
// What makes a signing session restart-safe: SecretNonce cannot be stored,
|
||||
// so the manager keeps its seed and derives again. If that were not
|
||||
// reproducible a device that restarted mid-session would publish a partial
|
||||
// signature against a nonce nobody aggregated.
|
||||
val first = nonceOf(0, message, random).second
|
||||
val second = nonceOf(0, message, random).second
|
||||
|
||||
assertEquals(first.data.toHex(), second.data.toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same seed under a different message gives a different nonce`() {
|
||||
val random = "e".repeat(63) + "1"
|
||||
|
||||
// The safety property behind reusing the seed at all: one session signs one
|
||||
// message. Were the nonce independent of the message, a session that could
|
||||
// be re-pointed at another event would sign twice under one nonce, which
|
||||
// hands over the secret share.
|
||||
val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second
|
||||
val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second
|
||||
|
||||
assertFalse(first.data.toHex() == second.data.toHex())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure bits of a signing session's bookkeeping: who is signing, and with
|
||||
* which key.
|
||||
*/
|
||||
class FrostSigningSessionTest {
|
||||
private fun session(signerId: Int, signerIds: String?) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = signerId,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64),
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a member left out of the signer set is not a signer`() {
|
||||
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
|
||||
assertFalse(session(signerId = 2, signerIds = "0,1").isSigner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody is a signer until the coordinator has chosen`() {
|
||||
assertFalse(session(signerId = 0, signerIds = null).isSigner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the signer set keeps the order it was aggregated in`() {
|
||||
// FROST binds the set into the challenge, so this list is not a set of ids
|
||||
// but a sequence positionally matched to the aggregated nonce.
|
||||
assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signer set tag survives the trip through a tag array`() {
|
||||
val tags = FrostSigningEvents.assembleTags(
|
||||
sessionId = "session",
|
||||
dkgSessionId = "ceremony",
|
||||
signerIds = listOf(2, 0, 1)
|
||||
)
|
||||
|
||||
assertEquals("session", FrostSigningEvents.parseSessionId(tags))
|
||||
assertEquals("ceremony", FrostSigningEvents.parseKey(tags))
|
||||
assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ceremony that recorded no public shares reads back null rather than empty`() {
|
||||
// Ceremonies completed before the column existed. Signing falls back to not
|
||||
// cross-checking shares, which the FROST API allows, rather than refusing.
|
||||
val ceremony = DkgSession(
|
||||
id = "k".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
hostPublicKey = "h".repeat(66),
|
||||
round1Random = "1".repeat(64),
|
||||
round2AuxRandom = "2".repeat(64)
|
||||
)
|
||||
|
||||
assertEquals(null, ceremony.publicShareList())
|
||||
assertEquals(
|
||||
2,
|
||||
ceremony.copy(
|
||||
publicShares = listOf(
|
||||
Hex.encode(ByteArray(33) { 2 }),
|
||||
Hex.encode(ByteArray(33) { 3 })
|
||||
).joinToString(",")
|
||||
).publicShareList()?.size
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* What a submission has to survive: the trip through a group.
|
||||
*
|
||||
* The envelope is only worth having if the event inside it comes out the other
|
||||
* side unchanged -- same id, same author, same signature. The moment any of
|
||||
* those is rewritten in transit, a group can no longer hold work by anyone but
|
||||
* its own members, which is the whole reason submissions exist.
|
||||
*/
|
||||
class SubmissionEventTest {
|
||||
private val submitter = "a".repeat(64)
|
||||
private val outsider = "b".repeat(64)
|
||||
|
||||
/** A dialect written by somebody who is not in the group. */
|
||||
private fun outsiderDialect(): Event {
|
||||
val template = DialectEvent.build(
|
||||
name = "Sesotho",
|
||||
country = "Lesotho",
|
||||
language = "st",
|
||||
createdAt = 1_700_000_000L,
|
||||
)
|
||||
return Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = outsider,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = outsider,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
}
|
||||
|
||||
/** Send a submission template and read it back the way inbound does. */
|
||||
private fun roundTrip(payload: Event): SubmissionEvent {
|
||||
val template = SubmissionEvent.build(payload = payload, createdAt = 1_700_000_100L)
|
||||
val onTheWire = Event.fromJson(
|
||||
Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = submitter,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = submitter,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "",
|
||||
).toJson()
|
||||
)
|
||||
|
||||
return SubmissionEvent(
|
||||
id = onTheWire.id,
|
||||
pubKey = onTheWire.pubKey,
|
||||
createdAt = onTheWire.createdAt,
|
||||
tags = onTheWire.tags,
|
||||
content = onTheWire.content,
|
||||
sig = onTheWire.sig,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the payload comes back as the event that went in`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val payload = roundTrip(dialect).payload()
|
||||
|
||||
assertEquals(dialect.id, payload?.id)
|
||||
assertEquals(dialect.pubKey, payload?.pubKey)
|
||||
assertEquals(dialect.kind, payload?.kind)
|
||||
assertEquals(dialect.content, payload?.content)
|
||||
assertEquals(dialect.sig, payload?.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submitting does not make the submitter the author`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val submission = roundTrip(dialect)
|
||||
|
||||
assertEquals(submitter, submission.pubKey)
|
||||
assertEquals(outsider, submission.payload()?.pubKey)
|
||||
assertNotEquals(submission.pubKey, submission.payload()?.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the envelope names what it carries without being opened`() {
|
||||
val dialect = outsiderDialect()
|
||||
|
||||
val submission = roundTrip(dialect)
|
||||
|
||||
assertEquals(DialectEvent.KIND, submission.payloadKind())
|
||||
assertEquals(dialect.id, submission.payloadId())
|
||||
assertEquals(outsider, submission.payloadAuthor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a payload the group cannot read is null rather than empty`() {
|
||||
val submission = SubmissionEvent(
|
||||
id = "d".repeat(64),
|
||||
pubKey = submitter,
|
||||
createdAt = 1_700_000_100L,
|
||||
tags = arrayOf(),
|
||||
content = "not an event",
|
||||
sig = "",
|
||||
)
|
||||
|
||||
assertNull(submission.payload())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user