Merge branch 'test/inbound-edit-is-applied' into 'main'

Prove an inbound edit is applied (relay#46)

See merge request paulhitt/flamenet-messenger-android!61
This commit was merged in pull request #88.
This commit is contained in:
Paul Hitt
2026-09-04 22:36:27 +00:00
@@ -0,0 +1,125 @@
package io.flamenet.messenger.app
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import okhttp3.mockwebserver.MockWebServer
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* An inbound edit changes the message it names (§6.15).
*
* 🔴 **Written to settle a claim, not to add coverage.** The published spec
* says Android *"cannot yet apply one, because its transcript does not record a
* `mid` for each line"*, and that both blocks replies and reactions too. The
* transcript does record a `mid` — `MessageReactionTest` folds a reaction onto
* a message by id — so the stated reason is already false.
*
* ⚠️ But relay#46's rule is the point: **do not flip a claim because the code
* looks present; flip it because a run proved it, and say which run.**
* `applyEdit` was implemented and wired with nothing exercising it, because
* `InboundControlTest` covers the codec and says in its own header that the
* app-side application needs a `Vault` and is out of scope there. This is that
* run.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class InboundEditAppliedTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
@AfterTest fun stop() { server.shutdown() }
private class MapStorage : Vault.Storage {
private val records = mutableMapOf<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private val me = 1L
private val them = 2L
private val thread = Vault.threadForUser(2L)
private fun session(): Session {
val identity = DeviceIdentity.create()
val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
val base = server.url("/").toString()
return Session("alice", me, identity, vault,
RelayClient(base, "alice", "app pass word"),
MessengerApi(base, "alice", "app pass word"))
}
private fun Session.textOf(mid: String): String? =
history(thread).firstOrNull { it.id == mid }?.text
@Test
fun `an edit from the author rewrites the message it names`() {
val s = session()
s.record(thread, "frist post", mine = false, author = them, messageId = "m1")
s.record(thread, "untouched", mine = false, author = them, messageId = "m2")
assertTrue(s.applyEdit(thread, "m1", author = them, newText = "first post"))
assertEquals("first post", s.textOf("m1"), "the edit did not reach the transcript")
assertEquals("untouched", s.textOf("m2"), "an edit changed a message it did not name")
}
/** It survives a reload — the point is the transcript, not a live object. */
@Test
fun `the edit is persisted, not just applied in memory`() {
val s = session()
s.record(thread, "before", mine = false, author = them, messageId = "m1")
s.applyEdit(thread, "m1", author = them, newText = "after")
assertEquals("after", s.textOf("m1"))
assertTrue(s.history(thread).single().editedAt != null,
"the bubble has no way to show that it was edited")
}
/**
* 🔴 **An edit from anyone but the author is refused.** Without this, one
* contact could rewrite another's words in a transcript — which reads to
* the person as the sender having said something they never said.
*/
@Test
fun `an edit from somebody who did not write it is refused`() {
val s = session()
s.record(thread, "theirs", mine = false, author = them, messageId = "m1")
assertFalse(s.applyEdit(thread, "m1", author = 99L, newText = "forged"))
assertEquals("theirs", s.textOf("m1"), "somebody else rewrote this message")
}
/** And nobody else may rewrite what THIS device sent. */
@Test
fun `an edit of our own message is refused when it claims another author`() {
val s = session()
s.record(thread, "mine", mine = true, author = me, messageId = "m1")
assertFalse(s.applyEdit(thread, "m1", author = them, newText = "forged"))
assertEquals("mine", s.textOf("m1"))
}
@Test
fun `an edit naming a message this device does not hold changes nothing`() {
val s = session()
s.record(thread, "only one", mine = false, author = them, messageId = "m1")
assertFalse(s.applyEdit(thread, "absent", author = them, newText = "x"))
assertEquals("only one", s.textOf("m1"))
}
}