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
@@ -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<Triple<Long, String, String>> =
synchronized(claimedGroupNames) {
@@ -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<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 ------------------------------------------------------
@Test