PairIdentityFramesTest.kt

3.2 KB · raw

1package com.peachpasswords.sync23import org.junit.Assert.assertEquals4import org.junit.Assert.assertNull5import org.junit.Assert.assertTrue6import org.junit.Assert.fail7import org.junit.Test89/**10 * Regression coverage for M-08: the hand-rolled PAIR_IDENTITY encoder escaped11 * `"`, `\`, and control bytes; the parser stopped at the next unescaped quote12 * and never unescaped. Names containing those characters truncated the frame13 * and corrupted everything after it. The kotlinx.serialization-backed codec14 * round-trips every JSON escape and validates fields at the trust boundary.15 */16private const val MAX_DEVICE_NAME_FOR_TEST = 641718class PairIdentityFramesTest {19    private val base = LocalDeviceIdentity(20        deviceId = "client-1",21        deviceName = "Phone",22        deviceType = DeviceType.ANDROID,23        longTermPublicKeyHex = "cd".repeat(32),24        signingPublicKeyHex = "ef".repeat(32),25        relayUrl = "wss://relay.peachpasswords.com",26    )2728    @Test29    fun `round-trips a device name containing every JSON escape character`() {30        val tricky = "a\"b\\c\nd\te\rf"31        val wire = encodePairIdentityFrame(base.copy(deviceName = tricky))32        val parsed = parsePairIdentityFrame(wire)33        assertEquals(tricky, parsed.deviceName)34    }3536    @Test37    fun `decoder unescapes a literal backslash-quote in the name`() {38        // The legacy parser stopped at the first unescaped quote and read39        // `a\` as the value, then desynced the rest of the frame.40        val raw = """{"type":"PAIR_IDENTITY","device_id":"d","device_name":"a\"b","device_type":"android","long_term_public_key_hex":"${"cd".repeat(32)}","signing_public_key_hex":"${"ef".repeat(32)}","relay_url":"wss://r.example.com"}"""41        val parsed = parsePairIdentityFrame(raw.toByteArray())42        assertEquals("a\"b", parsed.deviceName)43    }4445    @Test46    fun `decoder rejects a non-hex long term public key`() {47        val raw = """{"type":"PAIR_IDENTITY","device_id":"d","device_name":"n","device_type":"android","long_term_public_key_hex":"${"zz".repeat(32)}","signing_public_key_hex":"${"ef".repeat(32)}","relay_url":"wss://r.example.com"}"""48        try {49            parsePairIdentityFrame(raw.toByteArray())50            fail("expected hex rejection")51        } catch (e: IllegalArgumentException) {52            assertTrue(e.message!!.contains("hex"))53        }54    }5556    @Test57    fun `signing key is null when omitted from the wire`() {58        val omitted = """{"type":"PAIR_IDENTITY","device_id":"d","device_name":"n","device_type":"android","long_term_public_key_hex":"${"cd".repeat(32)}","relay_url":"wss://r.example.com"}"""59        val parsed = parsePairIdentityFrame(omitted.toByteArray())60        assertNull(parsed.signingPublicKeyHex)61    }6263    @Test64    fun `wire format preserves the type discriminator and snake_case keys`() {65        val wire = String(encodePairIdentityFrame(base), Charsets.UTF_8)66        assertTrue("frame must include type discriminator",67            wire.contains("\"type\":\"PAIR_IDENTITY\""))68        assertTrue("frame must use snake_case keys", wire.contains("\"device_id\""))69        assertTrue("frame must use snake_case keys", wire.contains("\"long_term_public_key_hex\""))70    }71}