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 @Test fun request_carries_receiver_device_type() = runBlocking {119 val (receiverCh, sourceCh) = memoryPair()120 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)121 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }122 val request = receiveFrame(sourceCh) as AdoptionFrame.Request123 recvJob.cancel()124125 assertEquals("android", request.deviceType)126 assertEquals("receiver-ephemeral", request.deviceId)127 assertEquals("Receiver", request.deviceName)128 }129130 @Test fun denied_consent_grants_nothing() = runBlocking {131 val (receiverCh, sourceCh) = memoryPair()132 val rootKeyHex = "33".repeat(32)133 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))134 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)135136 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }137 val request = receiveFrame(sourceCh) as AdoptionFrame.Request138 val granted = grantAdoption(sourceCh, request, source) { 0L }139 recvJob.cancel()140141 assertNull(granted)142 assertTrue(source.consentAsked)143 assertTrue(source.upserted.isEmpty())144 assertNull(receiver.createCalledWith)145 }146147 @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {148 val (receiverCh, sourceCh) = memoryPair()149 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)150 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }151 receiveFrame(sourceCh)152 sendFrame(sourceCh, AdoptionFrame.Grant(153 rootKeyHex = "bad",154 vaultId = "00".repeat(32),155 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),156 ))157 recvJob.await()158159 assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)160 assertNull(receiver.createCalledWith)161 }162163 @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {164 val (receiverCh, sourceCh) = memoryPair()165 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)166 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }167 receiveFrame(sourceCh)168 sendFrame(sourceCh, AdoptionFrame.Grant(169 rootKeyHex = "22".repeat(32),170 vaultId = "00".repeat(32),171 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),172 ))173 recvJob.await()174175 assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)176 assertNull(receiver.createCalledWith)177 }178179 @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {180 val (_, sourceCh) = memoryPair()181 val rootKeyHex = "33".repeat(32)182 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))183 val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")184185 val result = withTimeoutOrNull(150) {186 187 grantAdoption(sourceCh, request, source) { 0L }188 }189190 assertNull(result)191 assertTrue(source.upserted.isEmpty())192 }193194 @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {195 val (receiverCh, sourceCh) = memoryPair()196 val rootKeyHex = "44".repeat(32)197 val vaultId = vaultIdFor(rootKeyHex)198 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)199 val recvJob = async {200 runCatching { requestAdoption(receiverCh, receiver) { 0L } }201 }202 receiveFrame(sourceCh)203 sendFrame(sourceCh, AdoptionFrame.Grant(204 rootKeyHex = rootKeyHex,205 vaultId = vaultId,206 license = "PPK-test",207 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),208 ))209 val outcome = recvJob.await()210211 assertTrue(outcome.isFailure)212 assertNull(receiver.licenseApplied)213 }214215 @Test fun validator_rejects_short_and_uppercase_root_keys() {216 assertTrue(validateRootKeyHex("ab".repeat(32)))217 assertFalse(validateRootKeyHex("ab".repeat(31)))218 assertFalse(validateRootKeyHex("AB".repeat(32)))219 assertFalse(validateRootKeyHex("zz".repeat(32)))220 assertFalse(validateRootKeyHex(null))221 }222223 @Test fun grant_omits_absent_optionals_like_json_stringify() {224 val grant = AdoptionFrame.Grant(225 rootKeyHex = "11".repeat(32),226 vaultId = "22".repeat(32),227 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),228 )229 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)230231 assertFalse(json.contains("license"))232 assertFalse(json.contains("ws_port"))233 assertFalse(json.contains("signing_public_key_hex"))234 assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))235 assertTrue(json.contains("\"root_key_hex\""))236 }237}