Every comment is reduced to its first sentence plus @param, @return and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied with its default (official Kotlin) style across all modules; 262 files reformatted. The 264 remaining ktlint findings are naming and filename rules, which are code changes, and are left alone. Comments and formatting only. 1208 tests pass, counted from the XML. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
220 lines
6.9 KiB
Kotlin
220 lines
6.9 KiB
Kotlin
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<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.
|
|
*/
|
|
@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"))
|
|
}
|
|
}
|