Files
flamenet-messenger-android/app/src/test/kotlin/io/flamenet/messenger/app/ABlockedSenderStaysOutTest.kt
T
Paul HittandClaude Opus 5.5 88906aacb4 Keep a blocked contact out of the inbox on Android
The relay refuses a blocked sender only on an identified send. A sealed
send is authorised by the recipient's delivery key and does not say who
sent it, so a blocked contact who already holds that key keeps getting
through. Revoking delivery tokens does not close it: a refused token
falls back to the delivery key by design. The recipient learns the sender
when it unseals, so the recipient has to refuse.

Found on the iPhone-to-Android pair: after the iPhone blocked the
emulator, the emulator's next message went sealed, the relay accepted it
(blocks row 7 -> 6 present) and the iPhone showed it. This app had no
such check either.

The blocked list is kept in the vault, written on every block and unblock
and whenever the relay's list is fetched, and absorb() drops messages and
call signals from anyone on it. ABlockedSenderStaysOutTest: 2 red before,
4 green after; engine Vault tests 75 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:02:26 -04:00

138 lines
4.7 KiB
Kotlin

package io.flamenet.messenger.app
import io.flamenet.client.Conversations
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.MockWebServer
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
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.assertTrue
/**
* A blocked contact's messages do not reach the thread, sealed or not.
*
* Found on the iPhone-to-Android pair, 2026-10-08. The relay enforces a block
* only on an IDENTIFIED send (Routes.swift, `Block.blocks`). A sealed send is
* authorised by the recipient's delivery key and the relay does not know who
* sent it, so a blocked contact who already holds that key keeps getting
* through: the emulator sent sealed after the iPhone blocked it, the relay
* accepted it, and the iPhone showed it. Revoking delivery tokens does not
* help, a refused token falls back to the delivery key by design. The one
* place that knows the sender is the recipient, after it unseals, so the
* recipient has to refuse. This side did not either.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ABlockedSenderStaysOutTest {
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 blocked = 2L
private val friend = 3L
private fun session(storage: Vault.Storage = MapStorage(), key: SecretKey = key()): Session {
val identity = DeviceIdentity.create()
val vault = Vault(storage, 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 from(
user: Long,
text: String,
sealed: Boolean = true,
) = Conversations.Received(
id = user * 100,
fromUser = user,
fromDevice = "phone-$user",
content = Content.parse(Content.text(text, messageId = "m-$user"))!!,
sealed = sealed,
)
@Test
fun `a sealed message from a blocked contact is not filed`() {
val s = session()
s.vault.saveBlockedSenders(setOf(blocked))
val touched = s.absorb(listOf(from(blocked, "you blocked me and here I am"), from(friend, "hello")))
assertTrue(s.history(Vault.threadForUser(blocked)).isEmpty(), "a blocked contact's message reached the thread")
assertEquals(setOf(Vault.threadForUser(friend)), touched, "the block also stopped somebody it does not name")
}
/** Unblocking lets them back in. */
@Test
fun `unblocking lets them back in`() {
val s = session()
s.vault.saveBlockedSenders(setOf(blocked))
s.vault.saveBlockedSenders(emptySet())
s.absorb(listOf(from(blocked, "back again")))
assertEquals(1, s.history(Vault.threadForUser(blocked)).size)
}
/** It has to hold before the first fetch from the relay after a launch. */
@Test
fun `the block survives a restart`() {
val storage = MapStorage()
val k = key()
session(storage, k).vault.saveBlockedSenders(setOf(blocked))
assertEquals(setOf(blocked), session(storage, k).vault.blockedSenders())
}
/** Blocking and the relay's list both feed it. */
@Test
fun `blocking records the sender locally`() {
val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText()
val body = vm.substringAfter("fun setBlocked(").substringBefore("\n fun ")
assertTrue(body.contains("saveBlockedSenders("), "setBlocked never tells this device who is blocked")
}
}