From 19add570cba240e9692a01db6b7f8d97e8abb516 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 11 Sep 2026 07:20:17 -0400 Subject: [PATCH] Check the content envelope against envelopes the iPhone emitted The content layer had no vectors. Everything from section 6.6 onward was guarded only by same-code round trips: this engine encodes and parses through its own codec and would pass unchanged if it and the iPhone disagreed about every key. That is how SPEC 6.18 specified `ment` for as long as it has existed while both clients have always sent `men`. `content-v1.json` holds fifteen envelopes generated by RUNNING the iPhone's encoders, and this suite parses each one and asserts field by field. "It did not crash" is what the old tests already proved. One of them pins something easy to get wrong twice: the iPhone writes `fwd` as the integer 1, not as `true`, and `optBoolean` does not coerce a number. A parser reading only the boolean answers false and the message quietly loses the fact that it was passed on. The suite also fails if the vector file grows a case nobody asserts on. Separately, and the reason any of this is trustworthy: the test task did not track the vector files at all. They arrive by environment variable, Gradle does not track one as an input, so a vector could change under a build directory that already had results and the task reported UP-TO-DATE without running. That is not hypothetical here, it is how twenty vector tests once passed in CI for weeks without executing, on this same runner, whose build directory survives between runs. All six vector files are now declared inputs. Proven rather than asserted: with the inputs declared, flipping `men` to `ment` in one vector turns the suite red without `--rerun-tasks`. Before the fix the same change left it UP-TO-DATE and green. Full suite: 1294 tests, no failures. Co-Authored-By: Claude Opus 5 --- .e2e-ref | 2 +- .gitlab-ci.yml | 5 +- engine/build.gradle.kts | 30 +++ .../io/flamenet/e2e/ContentVectorTest.kt | 203 ++++++++++++++++++ 4 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/ContentVectorTest.kt diff --git a/.e2e-ref b/.e2e-ref index ed4057d..17fc940 100644 --- a/.e2e-ref +++ b/.e2e-ref @@ -1 +1 @@ -d8e1826a946fe5a1f956a3bb42dcb55969e3a890 +588929cca79e252c62fb8d50a77218bb4a6f884a diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d1a69e6..ae3975d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,12 +62,14 @@ unit-tests: - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/account-kdf-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-v1.json" - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-payload-v1.json" + - export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/content-v1.json" # Checked before the suite runs. A path that silently does not exist puts # the tests straight back where they were, and the failure would read as a # protocol bug rather than a missing file. - | for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \ - "$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS"; do + "$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \ + "$FLAMENET_CONTENT_VECTORS"; do [ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; } done # Clear old results before running, or the gate counts ghosts. The @@ -160,6 +162,7 @@ vectors-head: - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/account-kdf-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-v1.json" - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-payload-v1.json" + - export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/content-v1.json" # Only the module that reads vectors. The rest of the suite has nothing # to say about whether flamenet-e2e moved, and running it here would double # the board's cost to answer a question it cannot answer. diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 7623a47..a080895 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -58,6 +58,36 @@ tasks.test { events("passed", "failed", "skipped") showStandardStreams = true } + + // The vector files arrive by environment variable, and Gradle does not + // track an environment variable as an input. So a vector could move under + // a build directory that already had results in it and the task would + // report UP-TO-DATE without running a thing. + // + // That is not hypothetical. Twenty vector tests once "passed" in CI for + // weeks without ever executing, because the runner is a developer machine + // whose build directory survives between runs, and a replayed local pass + // looks exactly like a real one in the log. + // + // Declaring each file as an input is what makes a changed vector + // invalidate the task. Proven by changing one key in one vector and + // watching the suite go red without `--rerun-tasks`. + listOf( + "FLAMENET_VECTORS", + "FLAMENET_PQ_VECTORS", + "FLAMENET_ACCOUNT_VECTORS", + "FLAMENET_BACKUP_VECTORS", + "FLAMENET_BACKUP_PAYLOAD_VECTORS", + "FLAMENET_CONTENT_VECTORS", + ).forEach { name -> + val path = System.getenv(name) + if (path != null && file(path).exists()) { + inputs + .file(path) + .withPropertyName("vector-$name") + .withPathSensitivity(PathSensitivity.NONE) + } + } } // Coverage for this module. `:engine` and `:client` are plain JVM libraries diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ContentVectorTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ContentVectorTest.kt new file mode 100644 index 0000000..02392b1 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ContentVectorTest.kt @@ -0,0 +1,203 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import java.io.File as JFile + +/** + * This engine against envelopes the iPhone actually emitted. + * + * The content layer has been guarded only by same-code round trips: this engine + * encodes and parses through its own codec, and would pass unchanged if it and + * the iPhone disagreed about every key. The property those tests are believed + * to establish is cross-platform agreement, and they cannot fail on it. That is + * how SPEC 6.18 came to specify `ment` while both clients have always sent + * `men`, and how `ctl:true` cost a whole envelope on one platform only. + * + * These envelopes were generated by running the iPhone's encoders, not by + * reading them. Parsing is asserted field by field, because "it did not crash" + * is what the old tests already proved. + */ +class ContentVectorTest { + private val vectors: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_CONTENT_VECTORS"), + "../../engine/test/vectors/content-v1.json", + "../../../flamenet-e2e/test/vectors/content-v1.json", + "../../flamenet-e2e/test/vectors/content-v1.json", + ).map { JFile(it) } + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError( + "content vectors not found. Looked in: " + + candidates.joinToString(", ") { it.absolutePath }, + ) + JSONObject(found.readText()) + } + + private fun case(name: String): Content { + val cases = vectors.getJSONArray("cases") + for (i in 0 until cases.length()) { + val c = cases.getJSONObject(i) + if (c.getString("name") == name) return Content.parse(c.getString("envelope")) + } + throw AssertionError("no vector case named $name") + } + + @Test + fun `a plain text message parses as text`() { + val c = case("text") + assertEquals(Content.Kind.TEXT, c.kind) + assertEquals("still on for saturday", c.text) + assertEquals("m-1", c.messageId) + } + + @Test + fun `everything a text envelope can carry survives at once`() { + val c = case("text-reply-link-forwarded-ttl") + assertEquals("passing this on", c.text) + assertEquals("m-0", c.about?.messageId) + assertEquals(9L, c.about?.author) + assertEquals("https://example.org", c.link?.url) + assertEquals(3600, c.lifetimeSeconds) + // The iPhone writes `fwd` as the NUMBER 1, not as true. `optBoolean` + // does not coerce a number, so a parser reading only that answers false + // and the message silently loses the fact that it was passed on. + assertTrue(c.isForwarded, "fwd arrives as 1, not true") + } + + @Test + fun `mentions arrive under men, which is not what the spec says`() { + val c = case("group-text-mentions") + // SPEC 6.18 specifies `ment`. Both shipped clients send `men`. The + // clients agree; the spec is the outlier. This vector is what makes + // that checkable rather than a matter of whose reading you trust. + assertEquals(listOf(12, 34), c.mentions) + assertEquals("gid-1", c.groupId) + } + + @Test + fun `a reaction never becomes a bubble`() { + val c = case("react") + assertEquals(Content.Kind.REACT, c.kind) + assertEquals("🔥", c.reaction) + assertEquals("", c.text, "a reaction with text would render as an empty message") + assertEquals("m-0", c.about?.messageId) + } + + @Test + fun `a story reaction and a story reply both say they are about a story`() { + assertTrue(case("react-to-story").reactsToStory) + assertTrue(case("story-reply").repliesToStory) + assertEquals("s-1", case("story-reply").about?.messageId) + } + + @Test + fun `an edit names the message it replaces`() { + val c = case("edit") + assertEquals(Content.Kind.EDIT, c.kind) + assertEquals("m-1", c.editOf) + assertEquals("fixed typo", c.text) + } + + @Test + fun `a pin names its message and carries no caption`() { + val c = case("pin") + assertEquals(Content.Kind.PIN, c.kind) + assertEquals("m-1", c.pins) + } + + @Test + fun `a sticker is a sticker and not empty text`() { + val c = case("sticker") + assertEquals(Content.Kind.STICKER, c.kind) + assertEquals("classic", c.sticker?.pack) + assertEquals("wave", c.sticker?.id) + assertEquals("", c.text) + } + + @Test + fun `a story made of words keeps its expiry`() { + val c = case("text-story") + assertEquals("hello from the hill", c.text) + assertEquals(1_700_600_000.0, c.textStoryExpires) + } + + @Test + fun `an image attachment arrives whole`() { + val c = case("image") + val a = assertNotNull(c.attachment) + assertEquals("att-1", a.id) + assertEquals(4096, a.bytes) + assertEquals(800, a.width) + assertEquals(600, a.height) + assertEquals(32, a.key.size) + assertEquals(32, a.digest.size) + assertEquals("look", c.text) + } + + @Test + fun `a voice note keeps its duration, which is its whole interface`() { + val a = assertNotNull(case("audio").attachment) + assertEquals(12.5, a.duration) + assertEquals("audio/mp4", a.mime) + } + + @Test + fun `a document keeps the name and label it was filed under`() { + val c = case("file") + val a = assertNotNull(c.attachment) + // `fn`, `cls` and `cmp` are top-level envelope keys, not part of `att`, + // and none of the three appears anywhere in SPEC.md. + assertEquals("contract.pdf", a.filename) + assertEquals(3, a.classification) + assertEquals(listOf(2, 7), a.compartments) + } + + @Test + fun `a story in a set knows which one it is`() { + val c = case("story-item") + val set = assertNotNull(c.storySet) + assertEquals("set-1", set.id) + assertEquals(1, set.i) + assertEquals(3, set.n) + } + + @Test + fun `an avatar announcement is recognised as one`() { + val c = case("avatar") + assertEquals("avatar", c.kind) + assertEquals("att-1", assertNotNull(c.attachment).id) + } + + @Test + fun `every vector in the file is covered by a test here`() { + // A vector nobody asserts on is a vector that proves nothing. This + // fails when somebody adds a case and no assertion to go with it. + val cases = vectors.getJSONArray("cases") + val named = (0 until cases.length()).map { cases.getJSONObject(it).getString("name") }.toSet() + val asserted = + setOf( + "text", + "text-reply-link-forwarded-ttl", + "group-text-mentions", + "react", + "react-to-story", + "story-reply", + "edit", + "pin", + "sticker", + "text-story", + "image", + "audio", + "file", + "story-item", + "avatar", + ) + assertEquals(emptySet(), named - asserted, "vectors with no assertion in this file") + } +} -- 2.54.0