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() 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")) } }