package com.peachpasswords.sync import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test private fun fakeCodecFactory(): ChirpCodecFactory = { _ -> object : ChirpCodec { override fun encode(payload: ByteArray): FloatArray { val out = FloatArray(maxOf(payload.size, 128)) for (i in payload.indices) { out[i] = (payload[i].toInt() and 0xff) / 255f } for (i in payload.size until out.size) { out[i] = 99f } return out } override fun decode(samples: FloatArray): ByteArray? { if (samples.size < CHIRP_PAYLOAD_LENGTH) return null val buf = ByteArray(CHIRP_PAYLOAD_LENGTH) for (i in 0 until CHIRP_PAYLOAD_LENGTH) { val v = samples[i] if (v == 99f) return null buf[i] = (v * 255f).toInt().toByte() } return buf } override fun free() = Unit } } private fun fakeAudio(injected: FloatArray? = null): Pair Unit> { var pending = injected ?: FloatArray(0) var playCount = 0 var closed = false val io = object : AudioIO { override val sampleRate: Int = 48_000 override suspend fun play(waveform: FloatArray) { playCount++ } override suspend fun pullSamples(): FloatArray { val out = pending pending = FloatArray(0) return out } override suspend fun close() { closed = true } } return io to { samples -> val next = FloatArray(pending.size + samples.size) pending.copyInto(next, 0) samples.copyInto(next, pending.size) pending = next } } private fun chirpWave(payload: ChirpPayload): FloatArray { val bytes = encodeChirpPayload(payload) val wave = FloatArray(128) for (i in bytes.indices) { wave[i] = (bytes[i].toInt() and 0xff) / 255f } for (i in bytes.size until wave.size) { wave[i] = 99f } return wave } class ChirpPairingTest { @Test fun `createPairingAttempt rolls fresh ephemerals and nonce`() { val a = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION)) val b = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION)) assertFalse(a.ephemeralPublicKey.contentEquals(b.ephemeralPublicKey)) assertEquals(16, a.sessionNonce.size) assertTrue(a.outgoingPayload.ephemeralPublicKey.contentEquals(a.ephemeralPublicKey)) assertEquals(deviceTypeToByte(DeviceType.EXTENSION), a.outgoingPayload.deviceType) } @Test fun `runChirpExchange returns remote payload when received`() = runBlocking { val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID)) val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION)) val (io, inject) = fakeAudio() inject(chirpWave(theirs.outgoingPayload)) val result = runChirpExchange( io = io, opts = ChirpExchangeOptions( attempt = ours, timeoutMs = 2_000, ), codecFactory = fakeCodecFactory(), ) assertTrue(result.remoteChirp.ephemeralPublicKey.contentEquals(theirs.ephemeralPublicKey)) assertTrue(result.remoteChirp.sessionNonce.contentEquals(theirs.sessionNonce)) assertEquals(deviceTypeToByte(DeviceType.ANDROID), result.remoteChirp.deviceType) } @Test fun `runChirpExchange ignores own chirp and keeps listening`() = runBlocking { val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION)) val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID)) val (io, inject) = fakeAudio(chirpWave(ours.outgoingPayload)) val peerWave = chirpWave(theirs.outgoingPayload) kotlin.concurrent.thread { Thread.sleep(150) inject(peerWave) } val result = runChirpExchange( io = io, opts = ChirpExchangeOptions( attempt = ours, timeoutMs = 3_000, ), codecFactory = fakeCodecFactory(), ) assertTrue(result.remoteChirp.ephemeralPublicKey.contentEquals(theirs.ephemeralPublicKey)) } @Test fun `runChirpExchange times out when no chirp arrives`() = runBlocking { val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION)) val (io, _) = fakeAudio() try { runChirpExchange( io = io, opts = ChirpExchangeOptions( attempt = ours, timeoutMs = 200, ), codecFactory = fakeCodecFactory(), ) } catch (err: ChirpExchangeError) { assertEquals(ChirpExchangeErrorKind.TIMEOUT, err.kind) return@runBlocking } throw AssertionError("expected timeout") } @Test fun `runChirpExchange raises version mismatch on future protocol`() = runBlocking { val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION)) val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID)) val tweaked = theirs.outgoingPayload.copy(version = 0x02) val (io, inject) = fakeAudio() inject(chirpWave(tweaked)) try { runChirpExchange( io = io, opts = ChirpExchangeOptions( attempt = ours, timeoutMs = 2_000, ), codecFactory = fakeCodecFactory(), ) } catch (err: ChirpExchangeError) { assertEquals(ChirpExchangeErrorKind.VERSION_MISMATCH, err.kind) return@runBlocking } throw AssertionError("expected version mismatch") } @Test fun `pair frame round-trips an optional Pro license`() { val frame = PairingFrame( type = "PAIR_IDENTITY", deviceId = "a", deviceName = "Pixel", deviceType = DeviceType.ANDROID, longTermPublicKeyHex = "0".repeat(64), signingPublicKeyHex = "1".repeat(64), license = "PPK-AAA-BBB", ) val json = encodePairingFrame(frame).decodeToString() assertTrue(json.contains("\"license\":\"PPK-AAA-BBB\"")) assertEquals("PPK-AAA-BBB", decodePairingFrame(json.encodeToByteArray()).license) // Omitted entirely when null (forward-compatible with peers that ignore it). val noLicense = encodePairingFrame(frame.copy(license = null)).decodeToString() assertFalse(noLicense.contains("license")) } @Test fun `decodes a browser PAIR_IDENTITY frame carrying a license`() { val browserJson = "{\"type\":\"PAIR_IDENTITY\"," + "\"device_id\":\"ext-1\",\"device_name\":\"Firefox\"," + "\"device_type\":\"extension\"," + "\"long_term_public_key_hex\":\"${"0".repeat(64)}\"," + "\"license\":\"PPK-XYZ-123\"}" assertEquals("PPK-XYZ-123", decodePairingFrame(browserJson.encodeToByteArray()).license) } @Test fun `runChirpPairing emits listening before timeout`() = runBlocking { val trail = mutableListOf() try { runChirpPairing( deps = object : ChirpPairingDeps { override suspend fun fetchLocalIdentity(): LocalChirpIdentity { return LocalChirpIdentity( deviceId = "ext", deviceName = "Firefox", deviceType = DeviceType.EXTENSION, longTermPublicKeyHex = "0".repeat(64), signingPublicKeyHex = "1".repeat(64), ) } override suspend fun completePairing(paired: PairedDevice) = Unit override suspend fun confirmSAS(sas: String): Boolean = true override suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String) = Unit override suspend fun getLocalSubnet(): String? = "192.168.1" override suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport { throw IllegalStateException("unreached") } override suspend fun openPairingServer(): PairingServer { throw IllegalStateException("unreached") } override suspend fun openAudio(): AudioIO { return object : AudioIO { override val sampleRate: Int = 48_000 override suspend fun play(waveform: FloatArray) = Unit override suspend fun pullSamples(): FloatArray = FloatArray(0) override suspend fun close() = Unit } } override val chirpTimeoutMs: Long = 200 override fun onProgress(progress: PairingProgress) { trail.add(progress) } }, role = PairingRole.RESPONDER, codecFactory = fakeCodecFactory(), ) } catch (_: ChirpPairingFailure) { // expected timeout } assertEquals(PairingPhase.IDLE, trail.first().phase) assertEquals(PairingPhase.LISTENING, trail[1].phase) } }