Merge branch 'test/backup-payload-vector' into 'main'

Check the backup payload against the published vector

See merge request paulhitt/flamenet-messenger-android!118
This commit was merged in pull request #145.
This commit is contained in:
Paul Hitt
2026-09-10 23:50:47 +00:00
4 changed files with 121 additions and 7 deletions
+1 -1
View File
@@ -1 +1 @@
32d3a8824b06e53a0204feebedfee6dd9d384e7b
d8e1826a946fe5a1f956a3bb42dcb55969e3a890
+3 -1
View File
@@ -61,12 +61,13 @@ unit-tests:
- export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/pqx3dh-v2.json"
- 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"
# 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"; do
"$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS"; do
[ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; }
done
# Clear old results before running, or the gate counts ghosts. The
@@ -158,6 +159,7 @@ vectors-head:
- export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/pqx3dh-v2.json"
- 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"
# 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.
@@ -143,10 +143,11 @@ object BackupPayload {
.put("at", m.optLong("at"))
.put("text", m.optString("text"))
m.optString("mid").takeIf { it.isNotEmpty() }?.let { out.put("mid", it) }
// The author rides along on one-to-one threads too. The iPhone reads it
// only for groups and ignores it elsewhere, which costs nothing and
// keeps an Android-to-Android restore whole.
if (m.has("author")) out.put("author", m.optLong("author"))
// The author rides along on group threads, where it says who spoke.
// Only when there is one: this app writes 0 on a one-to-one message,
// where the author is whoever is not you, and emitting that would put
// a claim in the file that the message came from user zero.
m.optLong("author", 0L).takeIf { it != 0L }?.let { out.put("author", it) }
m.optJSONObject("att")?.let {
out.put("att", attachmentToShared(it, m.optString("kind", Attachment.IMAGE)))
}
@@ -307,7 +308,9 @@ object BackupPayload {
.put("mine", m.optBoolean("mine"))
.put("at", m.optLong("at"))
.put("text", m.optString("text"))
.put("author", m.optLong("author"))
// Storage keeps an author on every message and reads a missing one as
// 0, so this is written only when the file carried one.
m.optLong("author", 0L).takeIf { it != 0L }?.let { out.put("author", it) }
m.optString("mid").takeIf { it.isNotEmpty() }?.let { out.put("mid", it) }
m.optJSONObject("att")?.let { att ->
out.put("kind", att.optString("kind", Attachment.IMAGE))
@@ -0,0 +1,109 @@
package io.flamenet.e2e
import org.json.JSONArray
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import java.io.File as JFile
/**
* The payload inside a §16 backup, against the published vector.
*
* §16 pinned the envelope and nothing else, so both apps sealed their own
* storage and refused each other's files. Both now claim one shape, and until
* this vector existed nothing checked that claim: each side's tests asserted
* against what the OTHER was believed to write, transcribed by hand from
* reading its source. Agreeing with your own reading of somebody else's code is
* not interoperability.
*
* The bar here is exact. Decode the canonical payload, encode it again, and the
* result must match byte for byte after both are put in a canonical order.
* Anything looser passes while a field quietly goes missing, which is the whole
* failure being guarded against.
*/
class BackupPayloadVectorTest {
private val vectors: JSONObject by lazy {
val candidates =
listOfNotNull(
System.getenv("FLAMENET_BACKUP_PAYLOAD_VECTORS"),
"../../engine/test/vectors/backup-payload-v1.json",
"../../../flamenet-e2e/test/vectors/backup-payload-v1.json",
"../../flamenet-e2e/test/vectors/backup-payload-v1.json",
).map { JFile(it) }
val found =
candidates.firstOrNull { it.exists() }
?: throw AssertionError(
"backup payload vectors not found. Looked in: " +
candidates.joinToString(", ") { it.absolutePath },
)
JSONObject(found.readText())
}
/** Sorted keys, so comparing two payloads compares content and not order. */
private fun canonical(value: Any?): String =
when (value) {
is JSONObject -> {
value
.keys()
.asSequence()
.sorted()
.joinToString(",", "{", "}") { k -> JSONObject.quote(k) + ":" + canonical(value.get(k)) }
}
is JSONArray -> {
(0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) }
}
// A whole number that arrives as a Double must not print as
// "3600.0" on one side and "3600" on the other; the difference is
// an artefact of the parser, not of the file.
is Double -> {
if (value == Math.floor(value) && !value.isInfinite()) value.toLong().toString() else value.toString()
}
is Number -> {
value.toString()
}
is String -> {
JSONObject.quote(value)
}
else -> {
value.toString()
}
}
@Test
fun `the canonical payload survives this app unchanged`() {
val pinned = vectors.getJSONObject("canonical")
val here = BackupPayload.toShared(BackupPayload.fromShared(pinned))
assertEquals(
canonical(pinned),
canonical(here),
"this app changed the shared payload on a round trip, so a backup written here " +
"is not the one an iPhone reads",
)
}
@Test
fun `the vector says what only one side keeps`() {
// A field missing from a vector looks exactly like a field nobody
// thought of. Naming them is what makes a gap a decision.
val listed = vectors.getJSONArray("divergences")
assertTrue(listed.length() > 0)
for (i in 0 until listed.length()) {
val d = listed.getJSONObject(i)
assertTrue(d.getString("field").isNotEmpty())
assertTrue(d.getString("detail").length > 40, "a divergence without its consequence is a note")
}
}
@Test
fun `the outbox is not in the vector, and must not be added`() {
// Both apps flush it every poll cycle, so a restored outbox sends at
// once; a backup opened months later would deliver forgotten messages.
assertTrue(!vectors.getJSONObject("canonical").has("outbox"))
}
}