Put the app back to the last state that worked
Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to fa92581 — the tree that shipped as v8 and works.
Why they broke: those features are not independent. A message, a call
signal, a story and a story reply are the same content envelope with a
different kind, so they run through the same few functions in Content.kt.
Twenty-three MRs, each written against an older main and green on its own
branch, had never existed together until they were merged in one evening.
The conflicts that produced were resolved mechanically — union a parameter
list, merge a condition — and that produces code which compiles and passes
while being wrong: a field on the wrong struct, an argument in the wrong
order, a term in the wrong condition. Three were caught by the compiler.
The ones the compiler cannot see are why calls stopped connecting.
And the gate could not see any of it. 489 unit tests stayed green through
every break, because they test units: "assemble produces this JSON" is a
unit test, "Android shows what iOS sent" is not.
versionCode 10 so it installs over the broken v9.
Nothing is lost — every MR is still a merged commit with its branch on the
remote, and re-lands in small batches once there is a test that actually
sends a message between two devices.
This commit is contained in:
@@ -1,199 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A timer on one message rather than the whole conversation (§6.17).
|
||||
*
|
||||
* 🔴 **The rule that keeps this from making the product worse is that the
|
||||
* shorter timer always wins.** A per-message timer may only take life away,
|
||||
* never add it. Without that, a sender could defeat the recipient's
|
||||
* conversation timer by putting a long one on every message — turning a setting
|
||||
* the recipient chose, on their own device, into something the other party gets
|
||||
* to overrule.
|
||||
*/
|
||||
class PerMessageTimerTest {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
// ---------------------------------------------------------------- whose timer wins
|
||||
|
||||
@Test
|
||||
fun `a message timer applies when the conversation has none`() {
|
||||
assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the conversation timer applies when the message has none`() {
|
||||
assertEquals(3600, Vault.effectiveLifetime(message = 0, conversation = 3600))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The one that matters. A message asking to live a week, in a
|
||||
* conversation set to an hour, lives an hour.
|
||||
*/
|
||||
@Test
|
||||
fun `a longer message timer cannot extend a message's life`() {
|
||||
assertEquals(3600, Vault.effectiveLifetime(message = 604_800, conversation = 3600),
|
||||
"a sender overruled the recipient's own conversation timer")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shorter message timer shortens it`() {
|
||||
assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 3600))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no timer anywhere means it does not expire`() {
|
||||
assertNull(Vault.effectiveLifetime(message = 0, conversation = 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Zero means "no timer", not "expire immediately". Reading it the other
|
||||
* way would delete a message the instant it arrived.
|
||||
*/
|
||||
@Test
|
||||
fun `a negative timer is ignored rather than expiring immediately`() {
|
||||
assertNull(Vault.effectiveLifetime(message = -1, conversation = 0))
|
||||
assertEquals(3600, Vault.effectiveLifetime(message = -1, conversation = 3600))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pruning
|
||||
|
||||
private fun store(vault: Vault, threadId: String, at: Long, secs: Int, text: String) {
|
||||
vault.append(threadId, JSONObject()
|
||||
.put("text", text).put("mine", false).put("at", at)
|
||||
.also { if (secs > 0) it.put("secs", secs) })
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The bug the old code had.** Pruning returned early when the
|
||||
* CONVERSATION had no timer, so a timed message in an untimed thread was
|
||||
* kept for ever — the feature would have looked like it worked on the
|
||||
* sending phone and done nothing on the receiving one.
|
||||
*/
|
||||
@Test
|
||||
fun `a timed message expires even when the conversation has no timer`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val thread = Vault.threadForUser(7)
|
||||
val now = 1_000_000_000L
|
||||
store(vault, thread, at = now - 120_000, secs = 60, text = "should be gone")
|
||||
store(vault, thread, at = now, secs = 0, text = "should stay")
|
||||
|
||||
val kept = vault.pruneExpired(thread, now)
|
||||
assertEquals(1, kept.length())
|
||||
assertEquals("should stay", kept.getJSONObject(0).getString("text"))
|
||||
}
|
||||
|
||||
/** And the conversation timer still works on messages that carry none. */
|
||||
@Test
|
||||
fun `the conversation timer still expires untimed messages`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val thread = Vault.threadForUser(7)
|
||||
val now = 1_000_000_000L
|
||||
vault.saveDisappearSeconds(thread, 60)
|
||||
store(vault, thread, at = now - 120_000, secs = 0, text = "old")
|
||||
store(vault, thread, at = now, secs = 0, text = "new")
|
||||
|
||||
val kept = vault.pruneExpired(thread, now)
|
||||
assertEquals(1, kept.length())
|
||||
assertEquals("new", kept.getJSONObject(0).getString("text"))
|
||||
}
|
||||
|
||||
/** 🔴 A long message timer does not save a message from a short thread one. */
|
||||
@Test
|
||||
fun `a long message timer does not survive a short conversation timer`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val thread = Vault.threadForUser(7)
|
||||
val now = 1_000_000_000L
|
||||
vault.saveDisappearSeconds(thread, 60)
|
||||
store(vault, thread, at = now - 120_000, secs = 604_800, text = "asked to live a week")
|
||||
|
||||
assertEquals(0, vault.pruneExpired(thread, now).length(),
|
||||
"a sender overruled the recipient's conversation timer")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A message with no timestamp predates the field. Kept rather than
|
||||
* destroyed: an unknown age is not evidence of an old one, and deleting
|
||||
* somebody's history on a guess is unrecoverable.
|
||||
*/
|
||||
@Test
|
||||
fun `a message with no timestamp is kept`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val thread = Vault.threadForUser(7)
|
||||
vault.append(thread, JSONObject().put("text", "ancient").put("mine", false)
|
||||
.put("secs", 60))
|
||||
|
||||
assertEquals(1, vault.pruneExpired(thread, 1_000_000_000L).length())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the wire
|
||||
|
||||
/**
|
||||
* 🔴 **A duration, never a deadline.** An absolute expiry would be the
|
||||
* sender's clock: they could backdate a message so it vanished on arrival,
|
||||
* or forward-date one so it never expired.
|
||||
*/
|
||||
@Test
|
||||
fun `the envelope carries a duration not a deadline`() {
|
||||
val wire = Content.text("meet at 6", lifetimeSeconds = 60)
|
||||
assertTrue(wire.contains("\"secs\":60"), "wrong shape: $wire")
|
||||
assertEquals(60, Content.parse(wire).lifetimeSeconds)
|
||||
assertFalse(wire.contains("exp"), "the envelope carries a deadline: $wire")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **`Content.text` returns raw bytes when it has nothing to carry**, so a
|
||||
* timer that was not counted in that decision would be dropped and the
|
||||
* message would live for ever.
|
||||
*/
|
||||
@Test
|
||||
fun `a timed message always gets an envelope`() {
|
||||
val wire = Content.text("meet at 6", lifetimeSeconds = 60)
|
||||
assertTrue(wire.startsWith("{\"fnc\":1,"), "the timer was dropped: $wire")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an untimed plain message still goes out as raw bytes`() {
|
||||
assertEquals("meet at 6", Content.text("meet at 6"))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A conversation-timer announcement carries `secs` too and means
|
||||
* something entirely different. Reading both off one field is why this
|
||||
* checks the kind.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversation timer announcement is not a timed message`() {
|
||||
val announcement = Content.parse(Content.disappearing(3600))
|
||||
assertNull(announcement.lifetimeSeconds,
|
||||
"a conversation timer was read as one message's own")
|
||||
assertEquals(3600, Content.disappearSeconds(announcement))
|
||||
|
||||
val timed = Content.parse(Content.text("meet at 6", lifetimeSeconds = 60))
|
||||
assertNull(Content.disappearSeconds(timed),
|
||||
"a timed message was read as setting the whole conversation")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timer of zero is not written`() {
|
||||
assertFalse(Content.text("hi", messageId = "m", lifetimeSeconds = 0).contains("secs"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user