package io.flamenet.e2e import org.json.JSONObject 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 /** * A timer on one message rather than the whole conversation (ยง6.17). */ 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. */ @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". */ @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. */ @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. */ @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. */ @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. */ @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")) } }