From 556580f4fb764b6e8abd189e0798df6997859475 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 31 Aug 2026 06:16:56 -0400 Subject: [PATCH] Test the roster gate and the own-account announcement directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps left by the group-name work, both in the half that decides whether a claim is believed. The roster check that turns a group-name CLAIM into a stored name lives in Session, and was covered only through Conversations — which asserts the claim is NOT adopted there and says nothing about what happens next. iOS had its equivalent tested directly; Android's was a layer removed. It now has four tests: a member is adopted, a non-member is refused, a gid we are not in is refused, and a refused claim is consumed rather than left to retry against a roster that might later differ. Removing the membership test fails two of them. Reaching it needed a seam: `consumeControl` is internal to this module, on purpose, and driving it through a real ratchet would mean making a real sender claim a membership it does not have — which a real sender cannot be made to do. `consumeControlForTesting` says that in its own comment. The second gap: the announcement addresses our own account so a phone we own is not told by nobody, and nothing checked that it did. Removing our account from the list now fails. Co-Authored-By: Claude Opus 4.8 --- .../messenger/app/GroupNameClaimTest.kt | 176 ++++++++++++++++++ .../io/flamenet/client/Conversations.kt | 16 ++ .../io/flamenet/client/GroupNameTest.kt | 40 ++++ 3 files changed, 232 insertions(+) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/GroupNameClaimTest.kt diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/GroupNameClaimTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/GroupNameClaimTest.kt new file mode 100644 index 0000000..07638ee --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/GroupNameClaimTest.kt @@ -0,0 +1,176 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.SealedSender +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +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.assertNull +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The roster gate that turns a group-name CLAIM into a stored name. + * + * 🔴 **This is the check, and it lives here rather than in `Conversations`.** + * The layer that consumes control traffic does not know who is in which group, + * so it queues claims and `Session` decides. Anyone who can reach us can send + * one naming any gid they like; adopting on the sender's word would put + * whatever a stranger wants into somebody's chat list, under a group heading + * the reader has no reason to doubt. + * + * ⚠️ It was tested only through `Conversations` — which asserts the claim is + * NOT adopted there, and says nothing about what happens next. The half that + * decides was covered one layer removed on Android while iOS had it directly. + * That is the gap this closes. + */ +// ⚠️ Robolectric for the same reason as OutboxTest: `:app` is an Android +// module, so its unit-test classpath has the stubbed `org.json` that returns +// null from everything, and the vault stores JSON. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GroupNameClaimTest { + + private lateinit var server: MockWebServer + + /** Who the relay will say is in the group, for this run. */ + private var roster: List = emptyList() + + /** + * An empty inbox and whatever roster the test asked for. + * + * ⚠️ Answers by PATH rather than by queue order. `receive()` polls, and + * sending or draining can ask for a log head on the way — a fixture matched + * on order hands one of those the other's body and fails on a parse error + * a long way from the cause. + */ + private inner class Relay : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "/e2e/log/" in path -> MockResponse().setResponseCode(404) + "/e2e/groups" in path -> json( + """{"groups":[{"gid":"g1","name":"Group","owner":7,"members":[""" + + roster.joinToString(",") { """{"user_id":$it}""" } + "]}]}") + "/e2e/messages" in path -> json("""{"messages":[]}""") + else -> json("{}") + } + } + } + + @BeforeTest fun start() { + server = MockWebServer().also { it.dispatcher = Relay(); 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 fun session(): Pair { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } + val base = server.url("/").toString() + val s = Session( + handle = "alice", + userId = 1, + identity = identity, + vault = vault, + relay = RelayClient(base, "alice", "app pass word"), + api = MessengerApi(base, "alice", "app pass word"), + ) + return s to vault + } + + private fun json(body: String) = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) + + /** Put a claim in the queue the way an arriving announcement does. */ + private fun claim(session: Session, fromUser: Long, gid: String, name: String) { + session.conversations.consumeControlForTesting( + fromUser, + Content.parse(SealedSender.deliveryKeyEnvelope( + ByteArray(32), groupId = gid, groupName = name))) + } + + @Test + fun `a name from a member is adopted`() { + val (session, vault) = session() + roster = listOf(1L, 7L) + claim(session, 7L, "g1", "Book club") + + session.receive() + + assertEquals("Book club", vault.groupName("g1")) + } + + /** + * 🔴 The whole point. A stranger naming a group they are not in must get + * nowhere, however well-formed their envelope is. + */ + @Test + fun `a name from a non-member is refused`() { + val (session, vault) = session() + roster = listOf(1L, 7L) + claim(session, 99L, "g1", "Everything is fine") + + session.receive() + + assertNull(vault.groupName("g1")) + } + + /** + * ⚠️ A gid the relay does not list for us is not our group. Adopting it + * would let anyone create an entry in our chat list out of nothing. + */ + @Test + fun `a name for a group we are not in is refused`() { + val (session, vault) = session() + roster = listOf(1L, 7L) + claim(session, 7L, "some-other-gid", "Not ours") + + session.receive() + + assertNull(vault.groupName("some-other-gid")) + } + + /** + * ⚠️ Draining is not the same as accepting. A refused claim must not sit in + * the queue waiting to be retried against a roster that might later differ + * — and it must not be re-checked on every poll for ever. + */ + @Test + fun `a refused claim is consumed, not left to retry`() { + val (session, vault) = session() + roster = listOf(1L, 7L) + claim(session, 99L, "g1", "Everything is fine") + + session.receive() + assertNull(vault.groupName("g1")) + + // Now the stranger IS a member. The old claim must not be waiting. + roster = listOf(1L, 7L, 99L) + session.receive() + assertNull(vault.groupName("g1")) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 185e332..6bd2166 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -70,6 +70,22 @@ class Conversations( */ private val maxClaimedGroupNames = 256 + /** + * ⚠️ **Test seam, and the reason it exists is specific.** The roster check + * that turns one of these claims into a stored name lives in `Session`, a + * module up, so the test that proves a non-member is refused cannot reach + * `consumeControl` — it is `internal` here, deliberately, because it is the + * seam where a control envelope becomes a stored fact. + * + * Driving it through a real ratchet instead would mean standing up two + * parties and then making one of them claim a membership it does not have, + * which a real sender cannot be made to do. So the refusal is exercised by + * handing this layer the shape an attacker would produce, exactly as the + * iOS side does with its own receive path. + */ + fun consumeControlForTesting(fromUser: Long, content: Content) = + consumeControl(fromUser, content) + /** Take the pending claims, leaving none behind. */ fun takeClaimedGroupNames(): List> = synchronized(claimedGroupNames) { diff --git a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt index 7d12458..4b523b3 100644 --- a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt @@ -313,6 +313,46 @@ class GroupNameTest { assertFalse(unproven.senderProven) } + /** + * 🔴 **The announcement goes to our own account too, and that is the point.** + * Fan-out excludes us everywhere else, so a second phone we own would be + * told by nobody: the relay has no name to serve it and the members only + * send to each other. + * + * ⚠️ Safe only because the fan-out loop already skips our own device + * ("never talk to ourselves"). This asserts the recipient list, which is + * the half this file owns; the device-level skip has its own guard in the + * send path. + */ + @Test + fun `the announcement addresses our own account as well as the members`() { + val vault = vault() + vault.saveDelivery(Vault.Delivery(key = ByteArray(32) { 3 }, published = true)) + vault.saveGroupName("g1", "Book club") + val conversations = conversations(vault).also { it.myUserId = 1L } + + val asked = mutableListOf() + // Every send starts by listing the recipient's devices, so the paths + // the relay is asked for name exactly who was addressed. + server.dispatcher = object : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): + MockResponse { + val path = request.path.orEmpty() + Regex("/e2e/devices/(\\d+)").find(path)?.let { + asked += it.groupValues[1].toLong() + } + return MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"user_id":0,"devices":[]}""") + } + } + + conversations.announceGroupName("g1", listOf(2L, 3L)) + + assertTrue(1L in asked, "our own account was never addressed: $asked") + assertTrue(2L in asked && 3L in asked, "a member was missed: $asked") + } + // -- Where it is kept ------------------------------------------------------ @Test