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:
co-authored by
Claude Opus 4.8
parent
2b75f0e41b
commit
556580f4fb
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user