Android: photos, and a door for every capability
Owner feedback, and it was fair: capabilities kept landing in `:client` with a live test proving them and no way for a person to use them. Remove a contact. Block someone. Clear a thread. Change a password. Read a notification. All built, none reachable. So this is the CRUD pass, plus attachments. New in the UI: contact actions (block, unblock, clear chat, remove), a presence picker, a notifications screen with per-item and mark-all-read, an account screen with display name, password change and account deletion, password reset on the sign-in screen, bottom navigation, and photos -- sent through the system picker, rendered inline. `CrudSurfaceTest` walks the app and asserts the controls exist. It checks reachability rather than behaviour, because behaviour is what the live tests cover and the door is what was missing. It found two real gaps immediately: sign-out was reachable only from the contact list rather than from Account, where people look for it, and several controls sat below the fold. Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments, replies, reactions, group routing and call signalling. 17 unit tests. Two things it has to get right, and both are one-line mistakes: `fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A JSON library that sorts keys puts `att` first, every envelope it produces reads as plain text on the far side, and somebody's chat fills with raw JSON. The top level is assembled by hand. Control messages travel down the same channel as chat -- the web client sends `delivery_key`, `kt_head` and `sent` copies this way. A client that renders every envelope it does not recognise shows those to the user. They are now parsed, marked unrenderable, and handed to the caller with their payload. Attachments verified end to end against a real relay, including that the relay holds only ciphertext. That path was 401 on iOS for months because the attachment routes sent the account password, which the relay refuses on `/e2e/…`; this one uses the device-scoped token like everything else, and the test is what says so. Also: the instrumented suite passed one test at a time and failed as a set. The Compose rule gives each test a fresh Activity but the process and vault survive, so the app spends its first moments reading the vault behind a spinner -- and a reset that checks for a signed-in control immediately concludes "already signed out" while the session restores underneath it. 114 JVM tests, 37 cross-engine, 15 on-device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d30a77dd1e
commit
e65d1d987b
@@ -0,0 +1,201 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ContentTest {
|
||||
|
||||
private fun ref(kind: String = Attachment.IMAGE) = Attachment.Ref(
|
||||
kind = kind,
|
||||
id = "blob-1",
|
||||
key = ByteArray(32) { it.toByte() },
|
||||
digest = ByteArray(32) { (it * 3).toByte() },
|
||||
bytes = 1234,
|
||||
mime = "image/jpeg",
|
||||
width = 800,
|
||||
height = 600,
|
||||
)
|
||||
|
||||
/**
|
||||
* ⚠️ The marker is a **prefix test on the bytes**, so `fnc` must be
|
||||
* serialised first. A JSON library that sorts keys puts `att` first, every
|
||||
* envelope it produces reads as plain text on the far side, and somebody's
|
||||
* chat fills with raw JSON.
|
||||
*/
|
||||
@Test
|
||||
fun `every envelope starts with the marker`() {
|
||||
val envelopes = listOf(
|
||||
Content.text("hi", messageId = "m1"),
|
||||
Content.groupText("hi", groupId = "g1"),
|
||||
Content.attachment(ref(), caption = "look"),
|
||||
Content.reaction("👍", Content.Reference("m1", 7, "hi")),
|
||||
Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "c1", sdp = "v=0")),
|
||||
)
|
||||
envelopes.forEach { assertTrue(it.startsWith("{\"fnc\":1,"), "bad prefix: $it") }
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Plain 1:1 text stays raw UTF-8. A client predating content envelopes
|
||||
* shows an envelope as literal JSON, so the simplest message keeps the
|
||||
* format every client has always understood.
|
||||
*/
|
||||
@Test
|
||||
fun `plain text is not wrapped at all`() {
|
||||
assertEquals("just a message", Content.text("just a message"))
|
||||
assertEquals("just a message", Content.parse("just a message").text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message whose text happens to be JSON is still text`() {
|
||||
val tricky = """{"kind":"react","caption":"nope"}"""
|
||||
val parsed = Content.parse(tricky)
|
||||
assertEquals(tricky, parsed.text)
|
||||
assertNull(parsed.reaction)
|
||||
}
|
||||
|
||||
/** Not an envelope, because `fnc` is not 1. Must not be reinterpreted. */
|
||||
@Test
|
||||
fun `an envelope from a future version reads as text rather than vanishing`() {
|
||||
val future = """{"fnc":9,"kind":"hologram","caption":"hello"}"""
|
||||
assertEquals(future, Content.parse(future).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an attachment survives the round trip`() {
|
||||
val encoded = Content.attachment(ref(), caption = "a caption")
|
||||
val parsed = Content.parse(encoded)
|
||||
assertEquals("a caption", parsed.text)
|
||||
assertEquals(Attachment.IMAGE, parsed.kind)
|
||||
val got = parsed.attachment!!
|
||||
assertEquals("blob-1", got.id)
|
||||
assertEquals(800, got.width)
|
||||
assertEquals("image/jpeg", got.mime)
|
||||
assertContentEquals(ref().key, got.key)
|
||||
assertContentEquals(ref().digest, got.digest)
|
||||
assertTrue(parsed.isRenderable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reaction never becomes a message`() {
|
||||
val about = Content.Reference("m1", 7, "the original")
|
||||
val parsed = Content.parse(Content.reaction("🔥", about))
|
||||
assertEquals("🔥", parsed.reaction)
|
||||
assertEquals("", parsed.text)
|
||||
assertEquals("m1", parsed.about?.messageId)
|
||||
assertFalse(parsed.isRenderable)
|
||||
|
||||
// An empty emoji takes one back — a message, not an absence, because
|
||||
// the other device has already rendered the first one.
|
||||
assertEquals("", Content.parse(Content.reaction("", about)).reaction)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The web client sends `delivery_key`, `kt_head` and `sent` down this
|
||||
* same channel. A client that renders every envelope it does not recognise
|
||||
* shows those to the user as a bubble full of JSON.
|
||||
*/
|
||||
@Test
|
||||
fun `control messages are consumed rather than shown`() {
|
||||
for (kind in listOf("delivery_key", "kt_head", "sent")) {
|
||||
val parsed = Content.parse("""{"fnc":1,"kind":"$kind","key":"abc","to":3}""")
|
||||
assertEquals(kind, parsed.kind)
|
||||
assertFalse(parsed.isRenderable, "$kind would have been rendered")
|
||||
assertEquals("", parsed.text)
|
||||
// The envelope survives so a caller can act on it.
|
||||
assertEquals("abc", parsed.raw?.optString("key"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `call signals are not messages`() {
|
||||
val encoded = Content.callSignal(
|
||||
Content.CallSignal(Content.CallSignal.OFFER, "call-9", sdp = "v=0\r\n"))
|
||||
val parsed = Content.parse(encoded)
|
||||
assertEquals("call-9", parsed.call?.callId)
|
||||
assertEquals("v=0\r\n", parsed.call?.sdp)
|
||||
assertFalse(parsed.isRenderable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reply carries its own preview`() {
|
||||
val about = Content.Reference("m7", 42, "what was said before")
|
||||
val parsed = Content.parse(Content.text("answering", messageId = "m8", about = about))
|
||||
assertEquals("answering", parsed.text)
|
||||
assertEquals("what was said before", parsed.about?.preview)
|
||||
assertEquals(42, parsed.about?.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `group routing survives`() {
|
||||
val parsed = Content.parse(Content.groupText("to everyone", groupId = "g-99"))
|
||||
assertEquals("g-99", parsed.groupId)
|
||||
assertEquals("to everyone", parsed.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown attachment kind shows a label rather than disappearing`() {
|
||||
val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""")
|
||||
assertEquals("[unsupported attachment]", parsed.text)
|
||||
assertTrue(parsed.isRenderable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed key in a reference is refused, not used`() {
|
||||
val short = """{"fnc":1,"kind":"image","att":{"id":"x","key":"AAAA","digest":"AAAA"}}"""
|
||||
val parsed = Content.parse(short)
|
||||
assertNull(parsed.attachment)
|
||||
assertEquals("[unsupported attachment]", parsed.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `captions with quotes and newlines survive`() {
|
||||
val awkward = "she said \"hello\"\nand \\ left"
|
||||
assertEquals(awkward, Content.parse(Content.groupText(awkward, "g1")).text)
|
||||
}
|
||||
|
||||
// -- Blobs ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a blob round-trips`() {
|
||||
val data = ByteArray(5000) { (it % 251).toByte() }
|
||||
val sealed = Attachment.seal(data)
|
||||
val reference = ref().copy(key = sealed.key, digest = sealed.digest)
|
||||
assertContentEquals(data, Attachment.open(sealed.blob, reference))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two attachments never share a key`() {
|
||||
val a = Attachment.seal(ByteArray(16))
|
||||
val b = Attachment.seal(ByteArray(16))
|
||||
assertFalse(a.key.contentEquals(b.key))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The digest is checked BEFORE decrypting. AES-GCM would catch this too,
|
||||
* but failing on the digest names the problem — "the relay served different
|
||||
* bytes" — instead of reporting it as a decryption failure indistinguishable
|
||||
* from a broken session.
|
||||
*/
|
||||
@Test
|
||||
fun `a substituted blob is refused by digest, not by the cipher`() {
|
||||
val sealed = Attachment.seal("secret".toByteArray())
|
||||
val bent = sealed.blob.copyOf().also { it[it.size / 2] = (it[it.size / 2] + 1).toByte() }
|
||||
val failure = assertFailsWith<SecurityException> {
|
||||
Attachment.open(bent, ref().copy(key = sealed.key, digest = sealed.digest))
|
||||
}
|
||||
assertTrue(failure.message!!.contains("digest"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wrong key does not open a blob`() {
|
||||
val sealed = Attachment.seal("secret".toByteArray())
|
||||
assertFailsWith<Aead.DecryptionFailed> {
|
||||
Attachment.open(sealed.blob, ref().copy(key = ByteArray(32), digest = sealed.digest))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user