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")) + } +}