Prove an inbound edit is applied, so a spec claim can be corrected (relay#46)

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 18:36:21 -04:00
co-authored by Claude Opus 5
parent 93dd278434
commit 327da06ed3
@@ -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"))
}
}