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 ) : AdoptionReceiverDeps {60 var createCalledWith: String? = null61 var licenseApplied: String? = null62 val upserted = mutableListOf<PairedDevice>()63 val errors = mutableListOf<String>()64 override fun generateDeviceId(): String = "receiver-ephemeral"65 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {66 createCalledWith = rootKeyHex67 if (createThrows) throw IllegalStateException("commit_failed")68 return vaultId ?: error("no vault id")69 }70 override suspend fun deriveLocalIdentity(): LocalChirpIdentity = derived ?: error("no identity")71 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }72 override suspend fun applyLicense(license: String) { licenseApplied = license }73 override fun onError(error: String) { errors += error }74 }7576 private class RecorderSource(77 override val sourceIdentity: LocalChirpIdentity,78 private val rootKeyHex: String,79 private val vaultId: String,80 private val license: String? = null,81 private val consent: Boolean = true,82 ) : AdoptionSourceDeps {83 val upserted = mutableListOf<PairedDevice>()84 var consentAsked = false85 override suspend fun rootKeyHex(): String = rootKeyHex86 override suspend fun vaultId(): String = vaultId87 override suspend fun license(): String? = license88 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {89 consentAsked = true90 return consent91 }92 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }93 }9495 @Test fun completes_request_grant_identity_round_trip() = runBlocking {96 val (receiverCh, sourceCh) = memoryPair()97 val rootKeyHex = "11".repeat(32)98 val vaultId = vaultIdFor(rootKeyHex)99 val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)100 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)101 val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")102103 val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }104 val grantJob = async {105 val request = receiveFrame(sourceCh) as AdoptionFrame.Request106 grantAdoption(sourceCh, request, source) { 222L }107 }108 recvJob.await()109 grantJob.await()110111 assertEquals(rootKeyHex, receiver.createCalledWith)112 assertEquals("PPK-test", receiver.licenseApplied)113 assertEquals("source-id", receiver.upserted.single().deviceId)114 assertEquals("receiver-id", source.upserted.single().deviceId)115 assertEquals("android", source.upserted.single().deviceType)116 }117118 119120121122123124 @Test fun request_label_is_never_what_gets_persisted() = runBlocking {125 val (receiverCh, sourceCh) = memoryPair()126 val rootKeyHex = "55".repeat(32)127 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))128 129 val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")130131 val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }132 receiveFrame(receiverCh) 133 sendFrame(134 receiverCh,135 AdoptionFrame.Identity(136 deviceId = "receiver-id",137 deviceName = "Receiver",138 deviceType = "extension",139 longTermPublicKeyHex = "aa".repeat(32),140 signingPublicKeyHex = "bb".repeat(32),141 ),142 )143 grantJob.await()144145 val persisted = source.upserted.single()146 assertEquals("receiver-id", persisted.deviceId)147 assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)148 assertTrue(149 "the ephemeral request label must not reach the paired-device store",150 source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },151 )152 }153154 @Test fun request_carries_receiver_device_type() = runBlocking {155 val (receiverCh, sourceCh) = memoryPair()156 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)157 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }158 val request = receiveFrame(sourceCh) as AdoptionFrame.Request159 recvJob.cancel()160161 assertEquals("android", request.deviceType)162 assertEquals("receiver-ephemeral", request.sessionRequestId)163 assertEquals("Receiver", request.deviceName)164 }165166 @Test fun denied_consent_grants_nothing() = runBlocking {167 val (receiverCh, sourceCh) = memoryPair()168 val rootKeyHex = "33".repeat(32)169 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))170 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)171172 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }173 val request = receiveFrame(sourceCh) as AdoptionFrame.Request174 val granted = grantAdoption(sourceCh, request, source) { 0L }175 recvJob.cancel()176177 assertNull(granted)178 assertTrue(source.consentAsked)179 assertTrue(source.upserted.isEmpty())180 assertNull(receiver.createCalledWith)181 }182183 @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {184 val (receiverCh, sourceCh) = memoryPair()185 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)186 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }187 receiveFrame(sourceCh)188 sendFrame(sourceCh, AdoptionFrame.Grant(189 rootKeyHex = "bad",190 vaultId = "00".repeat(32),191 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),192 ))193 recvJob.await()194195 assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)196 assertNull(receiver.createCalledWith)197 }198199 @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {200 val (receiverCh, sourceCh) = memoryPair()201 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)202 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }203 receiveFrame(sourceCh)204 sendFrame(sourceCh, AdoptionFrame.Grant(205 rootKeyHex = "22".repeat(32),206 vaultId = "00".repeat(32),207 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),208 ))209 recvJob.await()210211 assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)212 assertNull(receiver.createCalledWith)213 }214215 @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {216 val (_, sourceCh) = memoryPair()217 val rootKeyHex = "33".repeat(32)218 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))219 val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")220221 val result = withTimeoutOrNull(150) {222 223 grantAdoption(sourceCh, request, source) { 0L }224 }225226 assertNull(result)227 assertTrue(source.upserted.isEmpty())228 }229230 @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {231 val (receiverCh, sourceCh) = memoryPair()232 val rootKeyHex = "44".repeat(32)233 val vaultId = vaultIdFor(rootKeyHex)234 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)235 val recvJob = async {236 runCatching { requestAdoption(receiverCh, receiver) { 0L } }237 }238 receiveFrame(sourceCh)239 sendFrame(sourceCh, AdoptionFrame.Grant(240 rootKeyHex = rootKeyHex,241 vaultId = vaultId,242 license = "PPK-test",243 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),244 ))245 val outcome = recvJob.await()246247 assertTrue(outcome.isFailure)248 assertNull(receiver.licenseApplied)249 }250251 @Test fun validator_rejects_short_and_uppercase_root_keys() {252 assertTrue(validateRootKeyHex("ab".repeat(32)))253 assertFalse(validateRootKeyHex("ab".repeat(31)))254 assertFalse(validateRootKeyHex("AB".repeat(32)))255 assertFalse(validateRootKeyHex("zz".repeat(32)))256 assertFalse(validateRootKeyHex(null))257 }258259 @Test fun grant_omits_absent_optionals_like_json_stringify() {260 val grant = AdoptionFrame.Grant(261 rootKeyHex = "11".repeat(32),262 vaultId = "22".repeat(32),263 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),264 )265 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)266267 assertFalse(json.contains("license"))268 assertFalse(json.contains("ws_port"))269 assertFalse(json.contains("signing_public_key_hex"))270 assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))271 assertTrue(json.contains("\"root_key_hex\""))272 }273}