feat(subgroups): schema 17 -- four nullable columns, and not one of them a foreign key
Phase 2 of docs/subgroups.md. Somewhere to put a parent, now that Phase 1 can prove one. | table | column | filled from | trusted? | |---|---|---|---| | GroupKeyState | parentChatRoomId | the state's parent tag | yes -- the certificate was checked | | GroupKeyState | birthCertificateJson | the state's certificate tag | yes -- same | | ChatRoom | parentChatRoomId | the verified key state | yes | | DkgSession | parentChatRoomId | a tag on a ceremony proposal | **no** -- a screen's title | The trust column is the point of the table and is written into the KDoc of each one. Three of these are written only after a signature has been checked; the fourth is an unverified claim off a wire message, and a column that mixed the two would be a column no reader could act on. Nothing may be granted on the strength of `DkgSession.parentChatRoomId` that would not be granted without it. **None of the four is a foreign key, and that is the change most likely to be "fixed" by somebody later.** `GroupKeyState`, `DkgSession` and `GroupSignedEvent` all declare `ForeignKey(onDelete = CASCADE)` onto ChatRoom, so pointing a parent column at ChatRoom the same way is the obvious next move. It would mean deleting a parent room deletes every subgroup row beneath it -- and then, by their own cascades, each subgroup's messages, participants, key state, signing sessions and signed events. A user tidying away a group they had left would silently destroy a group they are still in. RESTRICT is no better: it would make a parent undeletable while any child row exists, which is a foreign key deciding a product question. And neither would work anyway, because a parent pointer routinely names a room this device does not have at all -- a member of a subgroup who was never in its parent holds the id off a certificate and nothing else. A dangling reference is the normal, expected state here, and readers resolve it with a lookup allowed to return null. **The certificate is stored whole, as JSON, rather than as its signature.** A signature plus a rule for rebuilding the event it covers is a rule that breaks silently the first time the certificate's shape changes: a rebuild differing by one byte hashes to an id whose signature fails, and is indistinguishable from a forgery. A few hundred bytes inside an encryption removes the class. The parent column beside it is an index into that event, never a second source of truth -- the two are written together or not at all. Four DAO reads, each with the limits of what it answers written down. `GroupKeyStateDao.getByParentChatRoomId`/`observe` list the children whose state this device holds, which is *verified* but not complete -- a certified child whose room was never created here leaves no state at all. `ChatRoomDao.observeByParentChatRoomId` lists the ones there is something to open, excluding soft-deleted rooms so a room the user cleared away does not reappear because its parent lists it. `DkgSessionDao.getByParentChatRoomId` is how a member gets back into a subgroup flow they closed the app during, since before the certificate is signed the ceremony is the only thing on the device that knows the flow was started. `AutoMigration(16, 17)`: nullable additions are a shape Room migrates itself, and 17.json exports with no new foreign key on any of the three tables. Nine tests in `SubgroupDaoJvmTest`, all on properties the compiler cannot see: a state and a room may each name a parent this device holds no room for; deleting a parent leaves its child, its child's key state and its child's lineage standing; the parent lists its children newest-first and filters on *which* parent rather than on having one; a soft-deleted subgroup drops out; and a ceremony round-trips the parent it was opened for. 386 common tests and 673 jvm tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 16,
|
||||
version = 17,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -269,6 +269,25 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// without the index that lookup reads every message on the device. Room
|
||||
// creates an index on its own.
|
||||
AutoMigration(from = 15, to = 16),
|
||||
// v17 adds four nullable columns for subgroups -- a group made by
|
||||
// another group, which certifies it with its own quorum. See
|
||||
// docs/subgroups.md.
|
||||
//
|
||||
// GroupKeyState gains `parentChatRoomId` and `birthCertificateJson`, the
|
||||
// parent's signature over this room's id and the index into it. ChatRoom
|
||||
// gains `parentChatRoomId`, a copy of the same verified value so the room
|
||||
// list needs no second read. DkgSession gains one too, which is an
|
||||
// unverified claim off a ceremony proposal and is read for nothing but a
|
||||
// screen's title.
|
||||
//
|
||||
// **None of the three is a foreign key.** A self-referential cascade on
|
||||
// ChatRoom would mean deleting a parent room deletes its subgroups and,
|
||||
// by their own cascades, those rooms' messages, participants, key states
|
||||
// and signed events. Each column also routinely names a room this device
|
||||
// does not have, since a subgroup member need never have been in the
|
||||
// parent, so a dangling value is the normal state. Adding nullable
|
||||
// columns is a shape Room migrates itself.
|
||||
AutoMigration(from = 16, to = 17),
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
@@ -66,6 +66,26 @@ interface ChatRoomDao {
|
||||
)
|
||||
fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow<List<LocalChatRoom>>
|
||||
|
||||
/**
|
||||
* The subgroups of [parentChatRoomId] this device actually holds rooms for.
|
||||
*
|
||||
* Deliberately not the answer to "what subgroups does this group have" --
|
||||
* that is `SubgroupManager.subgroupsOf`, which reads the parent's signed
|
||||
* certificates and so can name a child whose room has not been created, or
|
||||
* was created for somebody else. This names the ones there is something to
|
||||
* open.
|
||||
*
|
||||
* Soft-deleted rooms are excluded like everywhere else: a room the user has
|
||||
* cleared away should not reappear because its parent lists it.
|
||||
*/
|
||||
@Transaction
|
||||
@Query(
|
||||
CHAT_ROOM_WITH_LAST_MESSAGE +
|
||||
"WHERE ChatRoom.parentChatRoomId = :parentChatRoomId AND ChatRoom.deletedAt IS NULL " +
|
||||
ORDER_BY_LAST_ACTIVITY
|
||||
)
|
||||
fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<LocalChatRoom>>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(chatRoom: ChatRoom)
|
||||
|
||||
|
||||
@@ -37,6 +37,22 @@ interface DkgSessionDao {
|
||||
@Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC")
|
||||
suspend fun getKeyHoldingSessions(): List<DkgSession>
|
||||
|
||||
/**
|
||||
* Ceremonies opened to make a subgroup of [parentChatRoomId], newest first.
|
||||
*
|
||||
* How a member gets back into a subgroup flow they closed the app during:
|
||||
* the child's room does not exist yet and its certificate may not be signed,
|
||||
* so the ceremony is the only thing on this device that knows the flow was
|
||||
* ever started.
|
||||
*
|
||||
* The parent named here is an unverified claim off the proposal -- see
|
||||
* `DkgSession.parentChatRoomId` -- so nothing may be granted on the strength
|
||||
* of appearing in this list. It decides which screen to offer, not who may
|
||||
* do what.
|
||||
*/
|
||||
@Query("SELECT * FROM DkgSession WHERE parentChatRoomId = :parentChatRoomId ORDER BY createdAt DESC")
|
||||
suspend fun getByParentChatRoomId(parentChatRoomId: String): List<DkgSession>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(dkgSession: DkgSession)
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ abstract class GroupKeyStateDao {
|
||||
@Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId")
|
||||
abstract suspend fun getByDkgSessionId(dkgSessionId: String): List<GroupKeyState>
|
||||
|
||||
/**
|
||||
* Every subgroup of [parentChatRoomId] this device holds a verified state
|
||||
* for.
|
||||
*
|
||||
* Verified is the whole of what these rows are: a state only reaches the
|
||||
* table with a parent on it once its birth certificate has been checked
|
||||
* against that parent's signature, so this is a list of children the parent
|
||||
* really did certify rather than a list of rooms claiming a parent.
|
||||
*
|
||||
* It is not the whole list. A subgroup whose room this device does not have
|
||||
* leaves no state here at all, and the certificates in `GroupSignedEvent` are
|
||||
* the fuller answer -- see `SubgroupManager.subgroupsOf`.
|
||||
*/
|
||||
@Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
|
||||
abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List<GroupKeyState>
|
||||
|
||||
@Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
|
||||
abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<GroupKeyState>>
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(groupKeyState: GroupKeyState)
|
||||
|
||||
|
||||
@@ -112,6 +112,29 @@ data class ChatRoom(
|
||||
*/
|
||||
val chronicleRequestedAt: Instant? = null,
|
||||
|
||||
/**
|
||||
* The group this room is a subgroup of, or null for a room that is nobody's
|
||||
* child.
|
||||
*
|
||||
* A copy of the verified `GroupKeyState.parentChatRoomId`, kept here so the
|
||||
* room list and the room's own screen can answer "whose child is this"
|
||||
* without a second read. It is written as the room is created or adopts its
|
||||
* key state, and only from a state whose certificate passed
|
||||
* `SubgroupBirthCertificateEvent.certifies`.
|
||||
*
|
||||
* **Never a foreign key onto `ChatRoom`.** Every other reference to a room in
|
||||
* this schema cascades, and a self-referential cascade would mean deleting a
|
||||
* parent silently deletes its subgroups -- and then, by their own cascades,
|
||||
* those rooms' messages, participants, key states, signing sessions and
|
||||
* signed events. A user tidying up a group they left would destroy a group
|
||||
* they are still in. `RESTRICT` would be no better: it would make a parent
|
||||
* undeletable while a child row exists, which is a foreign key deciding a
|
||||
* product question. This routinely names a room this device does not have --
|
||||
* see `GroupKeyState.parentChatRoomId` -- so a dangling value is the normal,
|
||||
* expected state.
|
||||
*/
|
||||
val parentChatRoomId: HexKey? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
|
||||
@@ -134,6 +134,28 @@ data class DkgSession(
|
||||
*/
|
||||
val approvalRequestedThrough: DkgApprovalStep? = null,
|
||||
|
||||
/**
|
||||
* The group this ceremony is being run to make a subgroup of, as the
|
||||
* proposal claimed -- or null for an ordinary ceremony.
|
||||
*
|
||||
* **This authenticates nothing.** It is read off a tag on the proposal, and
|
||||
* anybody can claim any parent. It exists for two things and neither of them
|
||||
* decides anything: the ritual screen saying "a subgroup of Ekklesia" rather
|
||||
* than "a shared key ceremony", and a member finding their way back into a
|
||||
* flow they closed the app halfway through.
|
||||
*
|
||||
* The load-bearing claim is the birth certificate, two steps later, which the
|
||||
* parent's own quorum signs and which any device can check against the
|
||||
* parent's room id alone. Nothing may be granted on the strength of this
|
||||
* column that would not be granted without it.
|
||||
*
|
||||
* Not a foreign key, for the reason `ChatRoom.parentChatRoomId` gives, and
|
||||
* with one more of its own: the room named here need never exist on this
|
||||
* device at all, since the claim is a stranger's until a certificate backs
|
||||
* it.
|
||||
*/
|
||||
val parentChatRoomId: HexKey? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
|
||||
@@ -93,6 +93,42 @@ data class GroupKeyState(
|
||||
/** The signed event's own timestamp, so the newest state per room wins. */
|
||||
val announcedAt: Instant,
|
||||
|
||||
/**
|
||||
* The group this one is a subgroup of, or null for a group that is nobody's
|
||||
* child -- which is every group made before subgroups existed.
|
||||
*
|
||||
* **Not a foreign key, and that is deliberate.** The three tables around this
|
||||
* one cascade off `ChatRoom`, and a self-referential cascade here would mean
|
||||
* deleting a parent room deletes every subgroup's state beneath it. It could
|
||||
* not be `RESTRICT` either: this routinely names a room the device does not
|
||||
* have at all -- a member of a subgroup who was never in its parent holds the
|
||||
* id and nothing else -- so a dangling value is the normal state and readers
|
||||
* resolve it with a lookup allowed to return null.
|
||||
*
|
||||
* Written only from a state whose [birthCertificateJson] passed
|
||||
* `SubgroupBirthCertificateEvent.certifies`, so a row carrying this is a row
|
||||
* where the parent's quorum really did sign for this room. See
|
||||
* `GroupKeyStateManager.stateFrom`, which drops the whole state rather than
|
||||
* keeping a parentage it could not check.
|
||||
*/
|
||||
val parentChatRoomId: HexKey? = null,
|
||||
|
||||
/**
|
||||
* The parent's signed certificate, whole, as JSON -- or null when this group
|
||||
* has no parent.
|
||||
*
|
||||
* The claim itself, where [parentChatRoomId] is only an index into it. Kept
|
||||
* as the event rather than as its signature so that a reader can check it
|
||||
* again from the row alone: a signature plus a rule for rebuilding the event
|
||||
* it covers is a rule that breaks silently the first time the certificate's
|
||||
* shape changes, and a rebuild differing by one byte hashes to an id whose
|
||||
* signature fails and is indistinguishable from a forgery.
|
||||
*
|
||||
* Always set together with [parentChatRoomId]. One without the other is a
|
||||
* state that never reached this table.
|
||||
*/
|
||||
val birthCertificateJson: String? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
|
||||
/**
|
||||
* What schema 17 has to survive, and the one thing it must refuse to do.
|
||||
*
|
||||
* Four nullable columns say a group is somebody's child. Three properties of
|
||||
* them are invisible to the compiler and each one is a way subgroups could break
|
||||
* quietly.
|
||||
*
|
||||
* A parent pointer **may name a room this device does not have**. A member of a
|
||||
* subgroup who was never in its parent holds the id and nothing else, so a
|
||||
* dangling value is the normal state rather than an error -- and if any of these
|
||||
* columns were ever given a foreign key, that member's rows would stop being
|
||||
* writable at all.
|
||||
*
|
||||
* Deleting a parent **must not touch its children**. Every other reference to a
|
||||
* room in this schema cascades. A self-referential cascade here would mean a user
|
||||
* tidying away a group they had left silently destroying a group they are still
|
||||
* in, together with its messages, participants, key state and signed work.
|
||||
*
|
||||
* And the two `GroupKeyState` columns **travel together**. One without the other
|
||||
* is a state whose parentage cannot be rechecked from the row, which is the whole
|
||||
* reason the certificate is stored whole rather than as a signature.
|
||||
*/
|
||||
class SubgroupDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
|
||||
private val parentRoom = "11".repeat(32)
|
||||
private val childRoom = "22".repeat(32)
|
||||
|
||||
/** A parent this device has never held a room for. */
|
||||
private val absentParent = "33".repeat(32)
|
||||
|
||||
private val certificate =
|
||||
"""{"id":"aa","pubkey":"bb","created_at":1700000000,"kind":30329,"tags":[],"content":"cc","sig":"dd"}"""
|
||||
|
||||
@Test
|
||||
fun `a key state keeps its parent and the certificate behind it`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = childRoom,
|
||||
parentChatRoomId = parentRoom,
|
||||
birthCertificateJson = certificate
|
||||
)
|
||||
)
|
||||
|
||||
val stored = db.groupKeyStateDao().getByChatRoomId(childRoom)
|
||||
|
||||
assertEquals(parentRoom, stored?.parentChatRoomId)
|
||||
assertEquals(certificate, stored?.birthCertificateJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group that is nobody's child keeps both columns null`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.groupKeyStateDao().upsert(keyState(chatRoomId = parentRoom))
|
||||
|
||||
val stored = db.groupKeyStateDao().getByChatRoomId(parentRoom)
|
||||
|
||||
// Every group made before subgroups existed reads back this way, and so
|
||||
// does every top-level group made after. Null here is not missing data.
|
||||
assertNull(stored?.parentChatRoomId)
|
||||
assertNull(stored?.birthCertificateJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a key state may name a parent this device holds no room for`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
// The position a subgroup member who was never in the parent is in: they
|
||||
// hold the id off a certificate and nothing else. If this column were a
|
||||
// foreign key their state could not be written at all.
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = childRoom,
|
||||
parentChatRoomId = absentParent,
|
||||
birthCertificateJson = certificate
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(absentParent, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
|
||||
assertNull(db.chatRoomDao().findChatRoomById(absentParent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a room may name a parent this device holds no room for`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = absentParent))
|
||||
|
||||
assertEquals(
|
||||
absentParent,
|
||||
db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting a parent room leaves its subgroup standing`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = childRoom,
|
||||
parentChatRoomId = parentRoom,
|
||||
birthCertificateJson = certificate
|
||||
)
|
||||
)
|
||||
|
||||
db.chatRoomDao().delete(room(parentRoom))
|
||||
|
||||
// The child survives with its lineage intact and pointing at a room that
|
||||
// is gone, which is exactly the dangling-reference case above. A cascade
|
||||
// here would have taken the room, its key state and everything either of
|
||||
// them owns.
|
||||
assertNull(db.chatRoomDao().findChatRoomById(parentRoom))
|
||||
assertEquals(
|
||||
parentRoom,
|
||||
db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
|
||||
)
|
||||
assertEquals(parentRoom, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parent lists the states of the children it certified, newest first`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
val second = "44".repeat(32)
|
||||
db.chatRoomDao().upsert(room(second))
|
||||
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = childRoom,
|
||||
parentChatRoomId = parentRoom,
|
||||
birthCertificateJson = certificate,
|
||||
announcedAt = Instant.fromEpochSeconds(1_000)
|
||||
)
|
||||
)
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = second,
|
||||
parentChatRoomId = parentRoom,
|
||||
birthCertificateJson = certificate,
|
||||
announcedAt = Instant.fromEpochSeconds(2_000)
|
||||
)
|
||||
)
|
||||
// Another group's child, to prove the filter is on the parent rather
|
||||
// than on having a parent at all.
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = parentRoom,
|
||||
parentChatRoomId = absentParent,
|
||||
birthCertificateJson = certificate
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(second, childRoom),
|
||||
db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.chatRoomId }
|
||||
)
|
||||
assertEquals(
|
||||
listOf(second, childRoom),
|
||||
db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first().map { it.chatRoomId }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parent lists only the subgroup rooms this device actually holds`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
|
||||
|
||||
val rooms = db.chatRoomDao().observeByParentChatRoomId(parentRoom).first()
|
||||
|
||||
assertEquals(listOf(childRoom), rooms.map { it.chatRoom.id })
|
||||
|
||||
// A child that was certified but whose room was never created here shows
|
||||
// up nowhere in this query. That is the difference between it and
|
||||
// SubgroupManager.subgroupsOf, which reads the parent's certificates.
|
||||
assertTrue(db.chatRoomDao().observeByParentChatRoomId(absentParent).first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a soft-deleted subgroup drops out of its parent's list`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
|
||||
db.chatRoomDao().upsert(
|
||||
room(childRoom).copy(
|
||||
parentChatRoomId = parentRoom,
|
||||
deletedAt = Instant.fromEpochSeconds(9_000)
|
||||
)
|
||||
)
|
||||
|
||||
// A room the user has cleared away must not come back because its parent
|
||||
// still lists it.
|
||||
assertTrue(db.chatRoomDao().observeByParentChatRoomId(parentRoom).first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ceremony remembers which group it was opened to make a subgroup of`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.dkgSessionDao().upsert(session("s1", parentChatRoomId = parentRoom))
|
||||
db.dkgSessionDao().upsert(session("s2"))
|
||||
|
||||
// Unverified, and read for nothing but a screen's title and a way back
|
||||
// into an unfinished flow -- but it has to survive a round trip or there
|
||||
// is no way back at all.
|
||||
assertEquals(
|
||||
listOf("s1"),
|
||||
db.dkgSessionDao().getByParentChatRoomId(parentRoom).map { it.id }
|
||||
)
|
||||
assertNull(db.dkgSessionDao().getSessionById("s2")?.parentChatRoomId)
|
||||
}
|
||||
|
||||
private suspend fun seedRooms() {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId)
|
||||
)
|
||||
listOf(parentRoom, childRoom).forEach { db.chatRoomDao().upsert(room(it)) }
|
||||
}
|
||||
|
||||
private fun room(id: String) = ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
|
||||
private fun keyState(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String? = null,
|
||||
birthCertificateJson: String? = null,
|
||||
announcedAt: Instant = Instant.fromEpochSeconds(1_700_000_000)
|
||||
) = GroupKeyState(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
thresholdPublicKey = "02" + "ab".repeat(32),
|
||||
derivationPath = "m/9420/0/0",
|
||||
announcedBy = user,
|
||||
announcedAt = announcedAt,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
birthCertificateJson = birthCertificateJson,
|
||||
)
|
||||
|
||||
private fun session(
|
||||
id: String,
|
||||
chatRoomId: String = parentRoom,
|
||||
parentChatRoomId: String? = null
|
||||
) = DkgSession(
|
||||
id = id,
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
stage = DkgRitualStage.COLLECTING_HOST_KEYS,
|
||||
hostPublicKey = user,
|
||||
round1Random = "aa".repeat(32),
|
||||
round2AuxRandom = "bb".repeat(32),
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user