1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import kotlinx.coroutines.withTimeoutOrNull7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Test12131415161718class ChirpAdoptTest {1920 private fun memoryPair(): Pair<PairingTransport, PairingTransport> {21 val a2b = Channel<ByteArray>(Channel.UNLIMITED)22 val b2a = Channel<ByteArray>(Channel.UNLIMITED)23 val a = object : PairingTransport {24 override suspend fun send(msg: ByteArray) { a2b.send(msg) }25 override suspend fun receive(): ByteArray = b2a.receive()26 override suspend fun close() {}27 }28 val b = object : PairingTransport {29 override suspend fun send(msg: ByteArray) { b2a.send(msg) }30 override suspend fun receive(): ByteArray = a2b.receive()31 override suspend fun close() {}32 }33 return a to b34 }3536 private fun identity(name: String) = LocalChirpIdentity(37 deviceId = "$name-id",38 deviceName = name,39 deviceType = DeviceType.EXTENSION,40 longTermPublicKeyHex = "ab".repeat(32),41 signingPublicKeyHex = "cd".repeat(32),42 )4344 private fun vaultIdFor(rootKeyHex: String): String {45 val bytes = hexToPubkey(rootKeyHex)46 return try {47 computeVaultId(bytes)48 } finally {49 bytes.fill(0)50 }51 }5253 private class RecorderReceiver(54 override val deviceName: String,55 override val deviceType: DeviceType,56 private val vaultId: String?,57 private val createThrows: Boolean = false,58 private val derived: LocalChirpIdentity? = null,59 private val sasConfirmed: Boolean = true,60 ) : AdoptionReceiverDeps {61 var createCalledWith: String? = null62 var licenseApplied: String? = null63 val upserted = mutableListOf<PairedDevice>()64 val errors = mutableListOf<String>()65 override fun generateDeviceId(): String = "receiver-ephemeral"66 override suspend fun confirmSas(): Boolean = sasConfirmed67 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {68 createCalledWith = rootKeyHex69 if (createThrows) throw IllegalStateException("commit_failed")70 return vaultId ?: error("no vault id")71 }72 override suspend fun deriveLocalIdentity(): LocalChirpIdentity = derived ?: error("no identity")73 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }74 override suspend fun applyLicense(license: String) { licenseApplied = license }75 override fun onError(error: String) { errors += error }76 }7778 private class RecorderSource(79 override val sourceIdentity: LocalChirpIdentity,80 private val rootKeyHex: String,81 private val vaultId: String,82 private val license: String? = null,83 private val consent: Boolean = true,84 ) : AdoptionSourceDeps {85 val upserted = mutableListOf<PairedDevice>()86 var consentAsked = false87 var rootRequested = false88 override suspend fun rootKeyHex(): String {89 rootRequested = true90 return rootKeyHex91 }92 override suspend fun vaultId(): String = vaultId93 override suspend fun license(): String? = license94 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {95 consentAsked = true96 return consent97 }98 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }99 }100101 @Test fun completes_request_grant_identity_round_trip() = runBlocking {102 val (receiverCh, sourceCh) = memoryPair()103 val rootKeyHex = "11".repeat(32)104 val vaultId = vaultIdFor(rootKeyHex)105 val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)106 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)107 val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")108109 val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }110 val grantJob = async {111 val request = receiveFrame(sourceCh) as AdoptionFrame.Request112 grantAdoption(sourceCh, request, source) { 222L }113 }114 recvJob.await()115 grantJob.await()116117 assertEquals(rootKeyHex, receiver.createCalledWith)118 assertEquals("PPK-test", receiver.licenseApplied)119 assertEquals("source-id", receiver.upserted.single().deviceId)120 assertEquals("receiver-id", source.upserted.single().deviceId)121 assertEquals("android", source.upserted.single().deviceType)122 }123124 125126127128129130 @Test fun request_label_is_never_what_gets_persisted() = runBlocking {131 val (receiverCh, sourceCh) = memoryPair()132 val rootKeyHex = "55".repeat(32)133 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))134 135 val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")136137 val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }138 sendFrame(receiverCh, AdoptionFrame.Confirm(true))139 receiveFrame(receiverCh) 140 sendFrame(141 receiverCh,142 AdoptionFrame.Identity(143 deviceId = "receiver-id",144 deviceName = "Receiver",145 deviceType = "extension",146 longTermPublicKeyHex = "aa".repeat(32),147 signingPublicKeyHex = "bb".repeat(32),148 ),149 )150 grantJob.await()151152 val persisted = source.upserted.single()153 assertEquals("receiver-id", persisted.deviceId)154 assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)155 assertTrue(156 "the ephemeral request label must not reach the paired-device store",157 source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },158 )159 }160161 @Test fun request_carries_receiver_device_type() = runBlocking {162 val (receiverCh, sourceCh) = memoryPair()163 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)164 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }165 val request = receiveFrame(sourceCh) as AdoptionFrame.Request166 recvJob.cancel()167168 assertEquals("android", request.deviceType)169 assertEquals("receiver-ephemeral", request.sessionRequestId)170 assertEquals("Receiver", request.deviceName)171 }172173 @Test fun denied_consent_grants_nothing() = runBlocking {174 val (receiverCh, sourceCh) = memoryPair()175 val rootKeyHex = "33".repeat(32)176 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))177 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)178179 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }180 val request = receiveFrame(sourceCh) as AdoptionFrame.Request181 val granted = grantAdoption(sourceCh, request, source) { 0L }182 recvJob.cancel()183184 assertNull(granted)185 assertTrue(source.consentAsked)186 assertFalse(source.rootRequested)187 assertTrue(source.upserted.isEmpty())188 assertNull(receiver.createCalledWith)189 }190191 @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {192 val (receiverCh, sourceCh) = memoryPair()193 val rootKeyHex = "66".repeat(32)194 val receiver = RecorderReceiver(195 "Receiver",196 DeviceType.ANDROID,197 vaultIdFor(rootKeyHex),198 sasConfirmed = false,199 )200 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))201202 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }203 val request = receiveFrame(sourceCh) as AdoptionFrame.Request204 val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }205206 assertNull(receiverJob.await())207 assertNull(sourceJob.await())208 assertFalse(source.rootRequested)209 assertNull(receiver.createCalledWith)210 assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)211 }212213 @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {214 val (receiverCh, sourceCh) = memoryPair()215 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)216 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }217 receiveFrame(sourceCh)218 sendFrame(sourceCh, AdoptionFrame.Grant(219 rootKeyHex = "bad",220 vaultId = "00".repeat(32),221 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),222 ))223 recvJob.await()224225 assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)226 assertNull(receiver.createCalledWith)227 }228229 @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {230 val (receiverCh, sourceCh) = memoryPair()231 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)232 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }233 receiveFrame(sourceCh)234 sendFrame(sourceCh, AdoptionFrame.Grant(235 rootKeyHex = "22".repeat(32),236 vaultId = "00".repeat(32),237 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),238 ))239 recvJob.await()240241 assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)242 assertNull(receiver.createCalledWith)243 }244245 @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {246 val (receiverCh, sourceCh) = memoryPair()247 val rootKeyHex = "33".repeat(32)248 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))249 val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")250251 sendFrame(receiverCh, AdoptionFrame.Confirm(true))252 val result = withTimeoutOrNull(150) {253 254 grantAdoption(sourceCh, request, source) { 0L }255 }256257 assertNull(result)258 assertTrue(source.upserted.isEmpty())259 }260261 @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {262 val (receiverCh, sourceCh) = memoryPair()263 val rootKeyHex = "44".repeat(32)264 val vaultId = vaultIdFor(rootKeyHex)265 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)266 val recvJob = async {267 runCatching { requestAdoption(receiverCh, receiver) { 0L } }268 }269 receiveFrame(sourceCh)270 sendFrame(sourceCh, AdoptionFrame.Grant(271 rootKeyHex = rootKeyHex,272 vaultId = vaultId,273 license = "PPK-test",274 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),275 ))276 val outcome = recvJob.await()277278 assertTrue(outcome.isFailure)279 assertNull(receiver.licenseApplied)280 }281282 @Test fun validator_rejects_short_and_uppercase_root_keys() {283 assertTrue(validateRootKeyHex("ab".repeat(32)))284 assertFalse(validateRootKeyHex("ab".repeat(31)))285 assertFalse(validateRootKeyHex("AB".repeat(32)))286 assertFalse(validateRootKeyHex("zz".repeat(32)))287 assertFalse(validateRootKeyHex(null))288 }289290 @Test fun grant_omits_absent_optionals_like_json_stringify() {291 val grant = AdoptionFrame.Grant(292 rootKeyHex = "11".repeat(32),293 vaultId = "22".repeat(32),294 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),295 )296 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)297298 assertFalse(json.contains("license"))299 assertFalse(json.contains("ws_port"))300 assertFalse(json.contains("signing_public_key_hex"))301 assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))302 assertTrue(json.contains("\"root_key_hex\""))303 }304}