Post a story to a group (§6.24)

Taken by hand, like the two before it — the original was cut from a tree
whose text stories and story replies differ from this branch's.

🔴 The group's roster is the audience, and the story audience does NOT
apply. Those are two different lists making two different statements —
who may see this account's stories, and who is in a conversation — and
they will disagree. The roster wins, because a story addressed to a
group that silently dropped members would be a group message part of the
group cannot see.

`groupStoryReach` returns that overlap rather than a warning. A sender
who believes their story audience still applies is being misled by the
interface, and a disclosure that cannot name the consequence is not one.
The two categories deliberately do not double-count: somebody who is not
a contact was never on that list to be excluded from.

🔴 On receipt, a claimed group id is checked against our own roster. A
story is caught before the group-message branch, so without that check a
sender could attach the id of a group they are not in and have their
story shown as having come from it. The claim is dropped to null rather
than the story refused — the story is legitimately theirs; only the
claim about where it was posted is unverified.

⚠️ `gid` names the audience, never a destination. Rendering a group
story into the group's messages would make a story into a message, and
one that never expires out of it.
This commit is contained in:
Paul Hitt
2026-09-03 19:19:37 -04:00
parent adf7858940
commit 32e2f6b5df
4 changed files with 220 additions and 1 deletions
@@ -0,0 +1,95 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Stories posted to a group (spec §6.24, ios#55).
*
* 🔴 **The group's roster is the audience, and the sender's story audience does
* not apply.** Two different lists making two different statements — who may
* see this account's stories, and who is in a conversation. They will disagree,
* and the roster wins.
*/
class GroupStoryTest {
@Test
fun `every member but yourself is a recipient`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 3), me = 1,
allowedByAudience = setOf(2, 3), contacts = listOf(2, 3))
assertEquals(listOf(2L, 3L), reach.recipients)
}
/**
* 🔴 The hazard the disclosure exists for: somebody excluded from this
* account's stories still receives a story posted to a group they are in.
*/
@Test
fun `a member excluded from your stories still receives a group story`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 3), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2, 3))
assertEquals(listOf(2L, 3L), reach.recipients)
assertEquals(listOf(3L), reach.excludedByAudience)
}
/** The other half: a member who is not a contact at all. */
@Test
fun `a member who is not a contact is named separately`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 9), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2))
assertEquals(listOf(9L), reach.notContacts)
assertTrue(reach.excludedByAudience.isEmpty())
}
/**
* ⚠️ The categories must not double-count. Somebody who is not a contact
* is not "excluded from your stories" — they were never on that list.
*/
@Test
fun `a non-contact is not also counted as excluded`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 9), me = 1,
allowedByAudience = emptySet(), contacts = emptyList())
assertEquals(listOf(9L), reach.notContacts)
assertTrue(reach.excludedByAudience.isEmpty())
}
@Test
fun `nothing is disclosed when the lists agree`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2))
assertTrue(reach.excludedByAudience.isEmpty())
assertTrue(reach.notContacts.isEmpty())
}
// -- Storage --------------------------------------------------------------
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<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()
}
/** ⚠️ Without persisting `gid`, a reload forgets which story went where. */
@Test
fun `the group survives the vault round trip`() {
val vault = Vault(MapStorage(),
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(Vault.Story(
id = "s1", authorId = 7, mine = false,
ref = Attachment.Ref(
kind = Attachment.STORY, id = "b1", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = now + 600),
caption = "", postedAt = 1, gid = "g1")), now)
assertEquals("g1", vault.loadStories().single().gid)
}
}