feat: rebuild the group's signed record out of the rows it left behind
Phase 3 of docs/member-archive.md. `ArchiveManager.assemble` walks a room's rows, rebuilds each into the event the group signed, drops anything it cannot prove, and cuts the rest into pages. Nothing sends one yet. **The gate found a real bug, which is why it was the gate.** Signed events are not stored as events -- `FrostSigningManager.complete` applies one and what survives is a `Mantra*` row -- so an archive has to rebuild them with `toXEvent()` and stands or falls on that being byte-identical to what was signed. Every `toXEvent()` in the codebase turned out to be unused in production, written for exactly this and never called, so the "tag order matches build so the event id round-trips" comments on them were claims nothing had ever checked. One was wrong. `MantraArtifact.toArtifactEvent` put the alt tag last where `ArtifactEvent.build` puts it first, and left out the version metadata tag altogether -- because that tag is not on the artifact row at all. `fromArtifactEvent` reads the artifact's own fields and drops the version label, which `applyInnerEvent` has by then turned into the artifact's first `MantraArtifactVersion`. So the label is now a parameter, read off the initial version: the one whose `createdAt` is the artifact's, since `initialVersionOf` derives it from the same event. Neither fault would have surfaced as an error. Both produce a well-formed artifact whose id no longer matches its fields, which every receiver drops as a forgery, silently, one kind at a time. `ArchiveRoundTripTest` now signs each archivable kind with a real quorum, files it as a row, rebuilds it and asserts the signature still covers what comes out -- plus the negative case, that rebuilding with the wrong version label fails as a forgery rather than as a mistake, which is why the assembler reads the label rather than defaulting it. **The allowlist narrows from nine kinds to six, and this is the finding to read.** Only six of the thirteen nip30303 kinds ever reach a signing session; the rest travel as member rumors, vouched for by the MLS frame they arrived in and by nothing that survives leaving it. An artifact version is derived rather than signed -- which is fine, because applying the archived artifact derives it again and the chapters hanging off it keep their foreign key. Nothing builds a `TranslationEvent` at all. The contributor lists have no arm in `applyInnerEvent` that writes a row. And `TranslationChunkEvent` -- **the translated text itself** -- is submitted by `MantraDao.saveTranslation` as its author's rumor, because a translation is one member's work rather than a group decision. So an archive restores everything a translation hangs on and not the translation: a new member gets the dialects, the artifacts, the chapters, the source chunks, which translations exist and their chapter scaffolding, and none of the prose. That is a real limit rather than a detail, so it is written into the allowlist's own doc comment, into the plan's "what this does not do", and into a test named after it -- with the three ways out sketched and none of them taken here, because the cheapest gives up the property the rest of this rests on and the best is a product decision about whether translating is an act of the group or of a member. **Nothing unverifiable leaves.** Every rebuilt event is checked with `isSignedByRoom` against the same room id the recipient will use. Not politeness -- the receiver checks anyway -- but so the page count says what will actually arrive: a row from a member's rumor is dropped here rather than by the recipient. **Walked down the tree, not queried per kind.** Only dialects and artifacts have a by-room query and the rest hang off a parent, and the walk is also what puts an artifact's version label within reach. Order is settled afterwards by `inApplyOrder` rather than by the walk, since the walk groups by artifact and the foreign keys are by kind. **Paging is greedy against both caps**, because they bind different archives: a room of one-line dialects hits the count first and a room of chapters hits the bytes. An event too large for a page of its own is dropped with a log rather than failing the archive -- a chapter nobody can archive is a hole, a member who gets nothing is a bigger one. Assembling only; queueing moved to Phase 5, where the thing that decides when to send lives. That keeps this testable against a real database with no outbound path in the way. Seven tests over a real in-memory database seeded through `applyInnerEvent` itself, so what is archived is what a member's device really holds rather than rows built to suit the test: every payload verifies, all six kinds appear exactly as often as they were signed, the whole archive is in dependency order end to end, a member's unsigned dialect sitting in the same room is left out, an empty room archives nothing without failing, and two archives of identical rows do not share an id -- which is what stops two members answering one request from having their pages counted towards each other's total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -179,20 +179,38 @@ class ArchiveEventTest {
|
||||
// ---- What an archive may carry ---------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the allowlist is every kind an archive can restore, and only those`() {
|
||||
fun `the allowlist is every kind the group signs, and only those`() {
|
||||
listOf(
|
||||
DialectEvent.KIND,
|
||||
ArtifactEvent.KIND,
|
||||
ArtifactVersionEvent.KIND,
|
||||
ChapterEvent.KIND,
|
||||
ChunkEvent.KIND,
|
||||
TranslationArtifactVersionEvent.KIND,
|
||||
TranslationChapterEvent.KIND,
|
||||
TranslationChunkEvent.KIND,
|
||||
TranslationEvent.KIND,
|
||||
).forEach { assertTrue(ArchiveEvent.isArchivable(it), "kind $it should be archivable") }
|
||||
|
||||
assertEquals(9, ArchiveEvent.ARCHIVABLE_KINDS.size)
|
||||
assertEquals(6, ArchiveEvent.ARCHIVABLE_KINDS.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the kinds nobody signs are left out, translations included`() {
|
||||
// The limit worth knowing about before reading anything else here. An
|
||||
// archive can only carry what the receiver can check, and six of the
|
||||
// thirteen nip30303 kinds reach a signing session. The rest travel as
|
||||
// rumors -- empty signature, member author -- vouched for by the MLS
|
||||
// frame they arrived in and by nothing that survives leaving it.
|
||||
|
||||
// The translated text. MantraDao.saveTranslation submits it as the
|
||||
// member's own rumor, so an archive restores everything a translation
|
||||
// hangs on and not the translation.
|
||||
assertFalse(ArchiveEvent.isArchivable(TranslationChunkEvent.KIND))
|
||||
|
||||
// Never signed either: derived from the signed artifact on arrival, which
|
||||
// is what keeps a chapter's foreign key satisfied without archiving it.
|
||||
assertFalse(ArchiveEvent.isArchivable(ArtifactVersionEvent.KIND))
|
||||
|
||||
// Nothing builds one of these at all.
|
||||
assertFalse(ArchiveEvent.isArchivable(TranslationEvent.KIND))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -235,17 +253,18 @@ class ArchiveEventTest {
|
||||
// Every one of these is a foreign key in Room, so an archive applied the
|
||||
// other way round is a constraint violation rather than a wrong answer.
|
||||
assertTrue(rank(DialectEvent.KIND) < rank(ArtifactEvent.KIND))
|
||||
assertTrue(rank(ArtifactEvent.KIND) < rank(ArtifactVersionEvent.KIND))
|
||||
assertTrue(rank(ArtifactVersionEvent.KIND) < rank(ChapterEvent.KIND))
|
||||
assertTrue(rank(ChapterEvent.KIND) < rank(ChunkEvent.KIND))
|
||||
assertTrue(rank(ArtifactVersionEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
|
||||
assertTrue(rank(DialectEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
|
||||
assertTrue(rank(TranslationArtifactVersionEvent.KIND) < rank(TranslationChapterEvent.KIND))
|
||||
assertTrue(rank(ChapterEvent.KIND) < rank(TranslationChapterEvent.KIND))
|
||||
assertTrue(rank(ChunkEvent.KIND) < rank(TranslationChunkEvent.KIND))
|
||||
assertTrue(rank(TranslationChapterEvent.KIND) < rank(TranslationChunkEvent.KIND))
|
||||
assertTrue(rank(TranslationChunkEvent.KIND) < rank(TranslationEvent.KIND))
|
||||
assertTrue(rank(TranslationArtifactVersionEvent.KIND) < rank(TranslationEvent.KIND))
|
||||
assertTrue(rank(DialectEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
|
||||
|
||||
// A chapter and a translation artifact version both hang off an artifact
|
||||
// *version*, which is not archived: it is derived from the signed
|
||||
// artifact by the artifact's own arm in applyInnerEvent. So the artifact
|
||||
// has to land before either of them, and the dependency runs through a
|
||||
// kind that is not in this list at all.
|
||||
assertTrue(rank(ArtifactEvent.KIND) < rank(ChapterEvent.KIND))
|
||||
assertTrue(rank(ArtifactEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
|
||||
|
||||
// And the numbers really do disagree with the order, which is why this is
|
||||
// a list rather than a sortedBy { kind }.
|
||||
@@ -256,22 +275,22 @@ class ArchiveEventTest {
|
||||
@Test
|
||||
fun `sorting a page puts what is referenced before what refers to it`() {
|
||||
val jumbled = listOf(
|
||||
payload(TranslationEvent.KIND),
|
||||
payload(TranslationChapterEvent.KIND),
|
||||
payload(ChunkEvent.KIND),
|
||||
payload(DialectEvent.KIND),
|
||||
payload(TranslationArtifactVersionEvent.KIND),
|
||||
payload(ChapterEvent.KIND),
|
||||
payload(ArtifactEvent.KIND),
|
||||
payload(ArtifactVersionEvent.KIND),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
DialectEvent.KIND,
|
||||
ArtifactEvent.KIND,
|
||||
ArtifactVersionEvent.KIND,
|
||||
ChapterEvent.KIND,
|
||||
ChunkEvent.KIND,
|
||||
TranslationEvent.KIND,
|
||||
TranslationArtifactVersionEvent.KIND,
|
||||
TranslationChapterEvent.KIND,
|
||||
),
|
||||
ArchiveEvent.inApplyOrder(jumbled).map { it.kind }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package press.mantra.compose.nostr.archive
|
||||
|
||||
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 fr.acinq.bitcoin.crypto.frost.TweakCache
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraChapter
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
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.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
|
||||
/**
|
||||
* The assumption the whole archive rests on: a row can be turned back into the
|
||||
* event the group signed.
|
||||
*
|
||||
* Signed events are not stored as events. `FrostSigningManager.complete` applies
|
||||
* them and what survives is a `Mantra*` row, so an archive has to rebuild each
|
||||
* one with `toXEvent()` and hope it comes out byte-identical. If it does not,
|
||||
* the id changes, the signature no longer covers it, and every receiver drops
|
||||
* the payload as a forgery -- silently, one kind at a time.
|
||||
*
|
||||
* That is what the round-trip note on each `toXEvent` claims and what nothing
|
||||
* asserted until now. Every kind in `ArchiveEvent.ARCHIVABLE_KINDS` is checked
|
||||
* here against a real FROST quorum, because a signature that verifies is the
|
||||
* only evidence that the rebuild is faithful -- comparing fields would only
|
||||
* prove the test and the code agree about which fields matter.
|
||||
*
|
||||
* A kind that fails here cannot be archived at all, whatever the allowlist says.
|
||||
*/
|
||||
class ArchiveRoundTripTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
/** Stands in for a completed ceremony; nothing here is about the DKG. */
|
||||
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
|
||||
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
|
||||
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
|
||||
)
|
||||
|
||||
private val tweakCache: TweakCache = room.cache
|
||||
|
||||
/** The group's identity, which is also the room's id. */
|
||||
private val chatRoomId = room.hex
|
||||
|
||||
private val dialectId = "b".repeat(64)
|
||||
private val artifactVersionId = "c".repeat(64)
|
||||
private val chapterId = "d".repeat(64)
|
||||
private val translationArtifactVersionId = "e".repeat(64)
|
||||
|
||||
/** Exactly what `FrostSigningManager.unsignedEventOf` does. */
|
||||
private fun unsignedEventOf(template: EventTemplate<*>) = Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = chatRoomId,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
),
|
||||
pubKey = chatRoomId,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = ""
|
||||
)
|
||||
|
||||
/** A real quorum signing [eventId], in the manager's order. */
|
||||
private fun groupSignature(eventId: String): String {
|
||||
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 = keyMaterial.secretShares[signerId],
|
||||
publicShare = keyMaterial.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
}
|
||||
|
||||
val signingSession = Session.create(
|
||||
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
|
||||
nParticipants = participants,
|
||||
threshold = threshold,
|
||||
tweakCache = tweakCache,
|
||||
message = message
|
||||
)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
signingSession.sign(
|
||||
nonces[position].first,
|
||||
keyMaterial.secretShares[signerId],
|
||||
signerId.toUInt()
|
||||
).right!!
|
||||
}
|
||||
|
||||
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
|
||||
}
|
||||
|
||||
/** A template, signed by the group, the way a finished session leaves one. */
|
||||
private fun signed(template: EventTemplate<*>): Event {
|
||||
val unsigned = unsignedEventOf(template)
|
||||
|
||||
return Event(
|
||||
id = unsigned.id,
|
||||
pubKey = unsigned.pubKey,
|
||||
createdAt = unsigned.createdAt,
|
||||
kind = unsigned.kind,
|
||||
tags = unsigned.tags,
|
||||
content = unsigned.content,
|
||||
sig = groupSignature(unsigned.id)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The claim, for one kind: sign it, file it as a row, rebuild it from the
|
||||
* row, and the group's signature still covers what comes out.
|
||||
*/
|
||||
private fun assertRoundTrips(template: EventTemplate<*>, rebuild: (Event) -> Event?) {
|
||||
val signedEvent = signed(template)
|
||||
assertTrue(
|
||||
GroupKeyStateEvent.isSignedByRoom(signedEvent, chatRoomId),
|
||||
"the harness itself must produce a verifiable event"
|
||||
)
|
||||
|
||||
val rebuilt = assertNotNull(
|
||||
rebuild(signedEvent),
|
||||
"kind ${template.kind} did not survive being turned into a row"
|
||||
)
|
||||
|
||||
// Asserted before the signature so a failure says which half broke: an id
|
||||
// that moved means the tag order or a field changed in the rebuild, where
|
||||
// a signature that fails on a matching id would mean something stranger.
|
||||
assertEquals(
|
||||
signedEvent.id,
|
||||
rebuilt.id,
|
||||
"kind ${template.kind} came back with a different id"
|
||||
)
|
||||
assertEquals(
|
||||
signedEvent.sig,
|
||||
rebuilt.sig,
|
||||
"kind ${template.kind} lost the group's signature"
|
||||
)
|
||||
assertTrue(
|
||||
GroupKeyStateEvent.isSignedByRoom(rebuilt, chatRoomId),
|
||||
"kind ${template.kind} rebuilt into something the group's signature does not cover"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dialect survives the trip through a row`() {
|
||||
assertRoundTrips(
|
||||
DialectEvent.build(
|
||||
name = "isiZulu",
|
||||
country = "ZA",
|
||||
language = "zu",
|
||||
createdAt = 1_700_000_000L
|
||||
)
|
||||
) { event ->
|
||||
MantraDialect.fromDialectEvent(
|
||||
DialectEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig),
|
||||
chatRoomId
|
||||
)?.toDialectEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact survives the trip through a row`() {
|
||||
// The one that did not, when this test was written. Two faults, and the
|
||||
// second is the interesting one: `toArtifactEvent` put the alt tag last
|
||||
// where `build` puts it first, and it left out the version metadata
|
||||
// entirely -- because that tag is not on the row at all. It is consumed
|
||||
// into the artifact's first MantraArtifactVersion and dropped. So the
|
||||
// label has to come back from there, which is why it is a parameter.
|
||||
assertRoundTrips(
|
||||
ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "https://example.com/in-detention",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = "1.0",
|
||||
createdAt = 1_700_000_000L
|
||||
)
|
||||
) { event ->
|
||||
MantraArtifact.fromArtifactEvent(
|
||||
ArtifactEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig),
|
||||
chatRoomId
|
||||
)?.toArtifactEvent(versionLabel = "1.0")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact rebuilt with the wrong version label is not the one that was signed`() {
|
||||
// What the parameter costs if a caller guesses. The label was part of
|
||||
// what the group put its signature to, so a rebuild carrying a different
|
||||
// one is a different event -- and it fails as a forgery rather than as a
|
||||
// mistake, which is why the assembler reads it off the initial version
|
||||
// rather than defaulting it.
|
||||
val signedEvent = signed(
|
||||
ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "https://example.com/in-detention",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = "1.0",
|
||||
createdAt = 1_700_000_000L
|
||||
)
|
||||
)
|
||||
|
||||
val rebuilt = MantraArtifact.fromArtifactEvent(
|
||||
ArtifactEvent(
|
||||
signedEvent.id, signedEvent.pubKey, signedEvent.createdAt,
|
||||
signedEvent.tags, signedEvent.content, signedEvent.sig
|
||||
),
|
||||
chatRoomId
|
||||
)?.toArtifactEvent(versionLabel = "2.0")
|
||||
|
||||
assertNotNull(rebuilt)
|
||||
assertFalse(GroupKeyStateEvent.isSignedByRoom(rebuilt, chatRoomId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chapter survives the trip through a row`() {
|
||||
assertRoundTrips(
|
||||
ChapterEvent.build(
|
||||
artifactVersionId = artifactVersionId,
|
||||
name = "Chapter One",
|
||||
originalText = "He was a man of parts.",
|
||||
index = 0,
|
||||
wordCount = 6,
|
||||
characterCount = 22,
|
||||
createdAt = 1_700_000_000L
|
||||
)
|
||||
) { event ->
|
||||
MantraChapter.fromChapterEvent(
|
||||
ChapterEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig),
|
||||
chatRoomId
|
||||
)?.toChapterEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chunk survives the trip through a row`() {
|
||||
assertRoundTrips(
|
||||
ChunkEvent.build(
|
||||
chapterId = chapterId,
|
||||
text = "He was a man of parts.",
|
||||
index = 0,
|
||||
wordCount = 6,
|
||||
characterCount = 22,
|
||||
createdAt = 1_700_000_000L
|
||||
)
|
||||
) { event ->
|
||||
MantraChunk.fromChunkEvent(
|
||||
ChunkEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig),
|
||||
chatRoomId
|
||||
)?.toChunkEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a translation artifact version survives the trip through a row`() {
|
||||
assertRoundTrips(
|
||||
TranslationArtifactVersionEvent.build(
|
||||
artifactVersionId = artifactVersionId,
|
||||
dialectId = dialectId,
|
||||
name = "isiZulu",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
createdAt = 1_700_000_000L
|
||||
)
|
||||
) { event ->
|
||||
MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent(
|
||||
TranslationArtifactVersionEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
),
|
||||
chatRoomId
|
||||
)?.toTranslationArtifactVersionEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a translation chapter survives the trip through a row`() {
|
||||
assertRoundTrips(
|
||||
TranslationChapterEvent.build(
|
||||
translationArtifactVersionId = translationArtifactVersionId,
|
||||
chapterId = chapterId,
|
||||
index = 0,
|
||||
createdAt = 1_700_000_000L
|
||||
)
|
||||
) { event ->
|
||||
MantraTranslationChapter.fromTranslationChapterEvent(
|
||||
TranslationChapterEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
),
|
||||
chatRoomId
|
||||
)?.toTranslationChapterEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every archivable kind has a round-trip test above`() {
|
||||
// The list and this file have to move together. A kind added to the
|
||||
// allowlist without a case here is one nobody has checked can be rebuilt,
|
||||
// and the failure is silent: the receiver drops it as a forgery.
|
||||
assertEquals(
|
||||
6,
|
||||
ArchiveEvent.ARCHIVABLE_KINDS.size,
|
||||
"an archivable kind was added or removed; add or remove its round-trip case"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user