Test the roster gate and the own-account announcement directly

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-31 06:16:56 -04:00
co-authored by Claude Opus 4.8
parent 2b75f0e41b
commit 556580f4fb
3 changed files with 232 additions and 0 deletions
@@ -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<Long> = 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<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 fun session(): Pair<Session, Vault> {
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"))
}
}
@@ -70,6 +70,22 @@ class Conversations(
*/ */
private val maxClaimedGroupNames = 256 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. */ /** Take the pending claims, leaving none behind. */
fun takeClaimedGroupNames(): List<Triple<Long, String, String>> = fun takeClaimedGroupNames(): List<Triple<Long, String, String>> =
synchronized(claimedGroupNames) { synchronized(claimedGroupNames) {
@@ -313,6 +313,46 @@ class GroupNameTest {
assertFalse(unproven.senderProven) 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<Long>()
// 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 ------------------------------------------------------ // -- Where it is kept ------------------------------------------------------
@Test @Test