From 327da06ed3c17743fade5d6b8d5067a8884fc79a Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 18:36:21 -0400 Subject: [PATCH] Prove an inbound edit is applied, so a spec claim can be corrected (relay#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published spec says the Android client *"cannot yet apply one, because its transcript does not record a `mid` for each line"*, and that the same gap blocks replies and reactions. The transcript DOES record a mid, and `MessageReactionTest` already folds a reaction onto a message by id — so the stated reason is false and the consequence for §6.9 is false with it. ⚠️ But relay#46's rule is the point of this commit: **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 — `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, written so the correction has something behind it. Five tests, and the two that matter most are refusals: an edit from somebody who did not write the message, and an edit of our own message claiming another author. Without the author check a contact could rewrite another person's words in a transcript, which reads to the reader as the sender having said something they never said. Both proved by removing that check and watching exactly those two go red. Co-Authored-By: Claude Opus 5 --- .../messenger/app/InboundEditAppliedTest.kt | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/InboundEditAppliedTest.kt diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/InboundEditAppliedTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/InboundEditAppliedTest.kt new file mode 100644 index 0000000..e190f62 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/InboundEditAppliedTest.kt @@ -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() + 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")) + } +}